From afd1179c0da0fae65998425d3bc30ecb310e6ca2 Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Mon, 23 Mar 2020 10:57:03 +0300 Subject: [PATCH 001/179] [NP] Remove kbnUrl usage in discover/dashboard/visualize (#60016) * Remove kbnUrl usages from disciver/dashboard/visualize * Remove kbnUrl usage in angular_config * Wrap with encodeURIComponent * Fix reloading when base path Co-authored-by: Elastic Machine --- .../kibana/public/dashboard/legacy_imports.ts | 8 ---- .../public/dashboard/np_ready/application.ts | 30 +----------- .../dashboard/np_ready/dashboard_app.tsx | 48 +++++++++---------- .../public/dashboard/np_ready/legacy_app.js | 14 +++--- .../public/discover/get_inner_angular.ts | 12 +---- .../discover/np_ready/angular/discover.js | 23 +++++---- .../angular/doc_table/components/table_row.ts | 13 ++--- .../kibana/public/visualize/legacy_imports.ts | 5 -- .../public/visualize/np_ready/application.ts | 28 +---------- .../visualize/np_ready/editor/editor.js | 21 +++----- .../np_ready/listing/visualize_listing.js | 9 ++-- .../public/angular/angular_config.tsx | 2 +- 12 files changed, 65 insertions(+), 148 deletions(-) diff --git a/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts b/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts index 3f81bfe5aadf2..55e1475fcb03a 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts @@ -25,17 +25,9 @@ */ export { npSetup, npStart } from 'ui/new_platform'; - -export { KbnUrl } from 'ui/url/kbn_url'; -// @ts-ignore -export { KbnUrlProvider } from 'ui/url/index'; -export { IInjector } from 'ui/chrome'; export { absoluteToParsedUrl } from 'ui/url/absolute_to_parsed_url'; export { configureAppAngularModule, - IPrivate, migrateLegacyQuery, - PrivateProvider, - PromiseServiceCreator, subscribeWithScope, } from '../../../../../plugins/kibana_legacy/public'; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/application.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/application.ts index 9447b5384d172..877ccab99171d 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/application.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/application.ts @@ -29,13 +29,7 @@ import { PluginInitializerContext, } from 'kibana/public'; import { Storage } from '../../../../../../plugins/kibana_utils/public'; -import { - configureAppAngularModule, - IPrivate, - KbnUrlProvider, - PrivateProvider, - PromiseServiceCreator, -} from '../legacy_imports'; +import { configureAppAngularModule } from '../legacy_imports'; // @ts-ignore import { initDashboardApp } from './legacy_app'; import { EmbeddableStart } from '../../../../../../plugins/embeddable/public'; @@ -116,10 +110,7 @@ function mountDashboardApp(appBasePath: string, element: HTMLElement) { function createLocalAngularModule(core: AppMountContext['core'], navigation: NavigationStart) { createLocalI18nModule(); - createLocalPrivateModule(); - createLocalPromiseModule(); createLocalConfigModule(core); - createLocalKbnUrlModule(); createLocalTopNavModule(navigation); createLocalIconModule(); @@ -127,10 +118,7 @@ function createLocalAngularModule(core: AppMountContext['core'], navigation: Nav ...thirdPartyAngularDependencies, 'app/dashboard/Config', 'app/dashboard/I18n', - 'app/dashboard/Private', 'app/dashboard/TopNav', - 'app/dashboard/KbnUrl', - 'app/dashboard/Promise', 'app/dashboard/icon', ]); return dashboardAngularModule; @@ -142,14 +130,8 @@ function createLocalIconModule() { .directive('icon', reactDirective => reactDirective(EuiIcon)); } -function createLocalKbnUrlModule() { - angular - .module('app/dashboard/KbnUrl', ['app/dashboard/Private', 'ngRoute']) - .service('kbnUrl', (Private: IPrivate) => Private(KbnUrlProvider)); -} - function createLocalConfigModule(core: AppMountContext['core']) { - angular.module('app/dashboard/Config', ['app/dashboard/Private']).provider('config', () => { + angular.module('app/dashboard/Config', []).provider('config', () => { return { $get: () => ({ get: core.uiSettings.get.bind(core.uiSettings), @@ -158,14 +140,6 @@ function createLocalConfigModule(core: AppMountContext['core']) { }); } -function createLocalPromiseModule() { - angular.module('app/dashboard/Promise', []).service('Promise', PromiseServiceCreator); -} - -function createLocalPrivateModule() { - angular.module('app/dashboard/Private', []).provider('Private', PrivateProvider); -} - function createLocalTopNavModule(navigation: NavigationStart) { angular .module('app/dashboard/TopNav', ['react']) diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx index c0a0693431295..4e9942767186e 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx @@ -21,8 +21,6 @@ import moment from 'moment'; import { Subscription } from 'rxjs'; import { History } from 'history'; -import { IInjector } from '../legacy_imports'; - import { ViewMode } from '../../../../embeddable_api/public/np_ready/public'; import { SavedObjectDashboard } from '../saved_dashboard/saved_dashboard'; import { DashboardAppState, SavedDashboardPanel } from './types'; @@ -86,28 +84,26 @@ export interface DashboardAppScope extends ng.IScope { } export function initDashboardAppDirective(app: any, deps: RenderDeps) { - app.directive('dashboardApp', function($injector: IInjector) { - return { - restrict: 'E', - controllerAs: 'dashboardApp', - controller: ( - $scope: DashboardAppScope, - $route: any, - $routeParams: { - id?: string; - }, - kbnUrlStateStorage: IKbnUrlStateStorage, - history: History - ) => - new DashboardAppController({ - $route, - $scope, - $routeParams, - indexPatterns: deps.data.indexPatterns, - kbnUrlStateStorage, - history, - ...deps, - }), - }; - }); + app.directive('dashboardApp', () => ({ + restrict: 'E', + controllerAs: 'dashboardApp', + controller: ( + $scope: DashboardAppScope, + $route: any, + $routeParams: { + id?: string; + }, + kbnUrlStateStorage: IKbnUrlStateStorage, + history: History + ) => + new DashboardAppController({ + $route, + $scope, + $routeParams, + indexPatterns: deps.data.indexPatterns, + kbnUrlStateStorage, + history, + ...deps, + }), + })); } diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/legacy_app.js b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/legacy_app.js index 64abbdfb87d58..dbeaf8a98b461 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/legacy_app.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/legacy_app.js @@ -18,6 +18,7 @@ */ import { i18n } from '@kbn/i18n'; +import { parse } from 'query-string'; import dashboardTemplate from './dashboard_app.html'; import dashboardListingTemplate from './listing/dashboard_listing_ng_wrapper.html'; @@ -93,9 +94,8 @@ export function initDashboardApp(app, deps) { .when(DashboardConstants.LANDING_PAGE_PATH, { ...defaults, template: dashboardListingTemplate, - controller($injector, $location, $scope, kbnUrlStateStorage) { + controller($scope, kbnUrlStateStorage, history) { const service = deps.savedDashboards; - const kbnUrl = $injector.get('kbnUrl'); const dashboardConfig = deps.dashboardConfig; // syncs `_g` portion of url with query services @@ -106,13 +106,13 @@ export function initDashboardApp(app, deps) { $scope.listingLimit = deps.uiSettings.get('savedObjects:listingLimit'); $scope.create = () => { - kbnUrl.redirect(DashboardConstants.CREATE_NEW_DASHBOARD_URL); + history.push(DashboardConstants.CREATE_NEW_DASHBOARD_URL); }; $scope.find = search => { return service.find(search, $scope.listingLimit); }; $scope.editItem = ({ id }) => { - kbnUrl.redirect(`${createDashboardEditUrl(id)}?_a=(viewMode:edit)`); + history.push(`${createDashboardEditUrl(id)}?_a=(viewMode:edit)`); }; $scope.getViewUrl = ({ id }) => { return deps.addBasePath(`#${createDashboardEditUrl(id)}`); @@ -121,7 +121,7 @@ export function initDashboardApp(app, deps) { return service.delete(dashboards.map(d => d.id)); }; $scope.hideWriteControls = dashboardConfig.getHideWriteControls(); - $scope.initialFilter = $location.search().filter || EMPTY_FILTER; + $scope.initialFilter = parse(history.location.search).filter || EMPTY_FILTER; deps.chrome.setBreadcrumbs([ { text: i18n.translate('kbn.dashboard.dashboardBreadcrumbsTitle', { @@ -191,7 +191,7 @@ export function initDashboardApp(app, deps) { template: dashboardTemplate, controller: createNewDashboardCtrl, resolve: { - dash: function($route, kbnUrl, history) { + dash: function($route, history) { const id = $route.current.params.id; return ensureDefaultIndexPattern(deps.core, deps.data, history) @@ -208,7 +208,7 @@ export function initDashboardApp(app, deps) { // A corrupt dashboard was detected (e.g. with invalid JSON properties) if (error instanceof InvalidJSONProperty) { deps.core.notifications.toasts.addDanger(error.message); - kbnUrl.redirect(DashboardConstants.LANDING_PAGE_PATH); + history.push(DashboardConstants.LANDING_PAGE_PATH); return; } diff --git a/src/legacy/core_plugins/kibana/public/discover/get_inner_angular.ts b/src/legacy/core_plugins/kibana/public/discover/get_inner_angular.ts index a19278911507c..031e10e99289f 100644 --- a/src/legacy/core_plugins/kibana/public/discover/get_inner_angular.ts +++ b/src/legacy/core_plugins/kibana/public/discover/get_inner_angular.ts @@ -24,8 +24,6 @@ import angular from 'angular'; import { EuiIcon } from '@elastic/eui'; import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular'; import { CoreStart, LegacyCoreStart } from 'kibana/public'; -// @ts-ignore -import { KbnUrlProvider } from 'ui/url'; import { DataPublicPluginStart } from '../../../../../plugins/data/public'; import { Storage } from '../../../../../plugins/kibana_utils/public'; import { NavigationPublicPluginStart as NavigationStart } from '../../../../../plugins/navigation/public'; @@ -59,7 +57,6 @@ import { createRenderCompleteDirective } from './np_ready/angular/directives/ren import { initAngularBootstrap, configureAppAngularModule, - IPrivate, KbnAccessibleClickProvider, PrivateProvider, PromiseServiceCreator, @@ -106,7 +103,6 @@ export function initializeInnerAngularModule( createLocalI18nModule(); createLocalPrivateModule(); createLocalPromiseModule(); - createLocalKbnUrlModule(); createLocalTopNavModule(navigation); createLocalStorageModule(); createElasticSearchModule(data); @@ -166,12 +162,6 @@ export function initializeInnerAngularModule( .service('debounce', ['$timeout', DebounceProviderTimeout]); } -function createLocalKbnUrlModule() { - angular - .module('discoverKbnUrl', ['discoverPrivate', 'ngRoute']) - .service('kbnUrl', (Private: IPrivate) => Private(KbnUrlProvider)); -} - function createLocalPromiseModule() { angular.module('discoverPromise', []).service('Promise', PromiseServiceCreator); } @@ -223,7 +213,7 @@ function createPagerFactoryModule() { function createDocTableModule() { angular - .module('discoverDocTable', ['discoverKbnUrl', 'discoverPagerFactory', 'react']) + .module('discoverDocTable', ['discoverPagerFactory', 'react']) .directive('docTable', createDocTableDirective) .directive('kbnTableHeader', createTableHeaderDirective) .directive('toolBarPagerText', createToolBarPagerTextDirective) diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js index e45ab2a7d7675..278317ec2e87b 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js @@ -184,7 +184,6 @@ function discoverController( $timeout, $window, Promise, - kbnUrl, localStorage, uiCapabilities ) { @@ -255,6 +254,15 @@ function discoverController( } }); + // this listener is waiting for such a path http://localhost:5601/app/kibana#/discover + // which could be set through pressing "New" button in top nav or go to "Discover" plugin from the sidebar + // to reload the page in a right way + const unlistenHistoryBasePath = history.listen(({ pathname, search, hash }) => { + if (!search && !hash && pathname === '/discover') { + $route.reload(); + } + }); + $scope.setIndexPattern = async id => { await replaceUrlAppState({ index: id }); $route.reload(); @@ -310,6 +318,7 @@ function discoverController( stopStateSync(); stopSyncingGlobalStateWithUrl(); stopSyncingQueryAppStateWithStateContainer(); + unlistenHistoryBasePath(); }); const getTopNavLinks = () => { @@ -323,7 +332,7 @@ function discoverController( }), run: function() { $scope.$evalAsync(() => { - kbnUrl.change('/discover'); + history.push('/discover'); }); }, testId: 'discoverNewButton', @@ -391,9 +400,7 @@ function discoverController( testId: 'discoverOpenButton', run: () => { showOpenSearchPanel({ - makeUrl: searchId => { - return kbnUrl.eval('#/discover/{{id}}', { id: searchId }); - }, + makeUrl: searchId => `#/discover/${encodeURIComponent(searchId)}`, I18nContext: core.i18n.Context, }); }, @@ -751,7 +758,7 @@ function discoverController( }); if (savedSearch.id !== $route.current.params.id) { - kbnUrl.change('/discover/{{id}}', { id: savedSearch.id }); + history.push(`/discover/${encodeURIComponent(savedSearch.id)}`); } else { // Update defaults so that "reload saved query" functions correctly setAppState(getStateDefaults()); @@ -921,11 +928,11 @@ function discoverController( }; $scope.resetQuery = function() { - kbnUrl.change('/discover/{{id}}', { id: $route.current.params.id }); + history.push(`/discover/${encodeURIComponent($route.current.params.id)}`); }; $scope.newQuery = function() { - kbnUrl.change('/discover'); + history.push('/discover'); }; $scope.updateDataSource = () => { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row.ts index 5d3f6ac199a46..698bfe7416d42 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row.ts @@ -41,11 +41,7 @@ interface LazyScope extends ng.IScope { [key: string]: any; } -export function createTableRowDirective( - $compile: ng.ICompileService, - $httpParamSerializer: any, - kbnUrl: any -) { +export function createTableRowDirective($compile: ng.ICompileService, $httpParamSerializer: any) { const cellTemplate = _.template(noWhiteSpace(cellTemplateHtml)); const truncateByHeightTemplate = _.template(noWhiteSpace(truncateByHeightTemplateHtml)); @@ -110,10 +106,9 @@ export function createTableRowDirective( }; $scope.getContextAppHref = () => { - const path = kbnUrl.eval('#/discover/context/{{ indexPattern }}/{{ anchorId }}', { - anchorId: $scope.row._id, - indexPattern: $scope.indexPattern.id, - }); + const path = `#/discover/context/${encodeURIComponent( + $scope.indexPattern.id + )}/${encodeURIComponent($scope.row._id)}`; const globalFilters: any = getServices().filterManager.getGlobalFilters(); const appFilters: any = getServices().filterManager.getAppFilters(); const hash = $httpParamSerializer({ diff --git a/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts b/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts index e6b7a29e28d89..a2e2ba3543104 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts @@ -24,8 +24,6 @@ * directly where they are needed. */ -// @ts-ignore -export { KbnUrlProvider } from 'ui/url'; export { absoluteToParsedUrl } from 'ui/url/absolute_to_parsed_url'; export { KibanaParsedUrl } from 'ui/url/kibana_parsed_url'; export { wrapInI18nContext } from 'ui/i18n'; @@ -33,9 +31,6 @@ export { DashboardConstants } from '../dashboard/np_ready/dashboard_constants'; export { VisSavedObject, VISUALIZE_EMBEDDABLE_TYPE } from '../../../visualizations/public/'; export { configureAppAngularModule, - IPrivate, migrateLegacyQuery, - PrivateProvider, - PromiseServiceCreator, subscribeWithScope, } from '../../../../../plugins/kibana_legacy/public'; diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts b/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts index c7c3286bb5c71..241397884c8fe 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts @@ -21,13 +21,7 @@ import angular, { IModule } from 'angular'; import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular'; import { AppMountContext } from 'kibana/public'; -import { - configureAppAngularModule, - KbnUrlProvider, - IPrivate, - PrivateProvider, - PromiseServiceCreator, -} from '../legacy_imports'; +import { configureAppAngularModule } from '../legacy_imports'; import { NavigationPublicPluginStart as NavigationStart } from '../../../../../../plugins/navigation/public'; import { createTopNavDirective, @@ -82,36 +76,16 @@ function mountVisualizeApp(appBasePath: string, element: HTMLElement) { function createLocalAngularModule(core: AppMountContext['core'], navigation: NavigationStart) { createLocalI18nModule(); - createLocalPrivateModule(); - createLocalPromiseModule(); - createLocalKbnUrlModule(); createLocalTopNavModule(navigation); const visualizeAngularModule: IModule = angular.module(moduleName, [ ...thirdPartyAngularDependencies, 'app/visualize/I18n', - 'app/visualize/Private', 'app/visualize/TopNav', - 'app/visualize/KbnUrl', - 'app/visualize/Promise', ]); return visualizeAngularModule; } -function createLocalKbnUrlModule() { - angular - .module('app/visualize/KbnUrl', ['app/visualize/Private', 'ngRoute']) - .service('kbnUrl', (Private: IPrivate) => Private(KbnUrlProvider)); -} - -function createLocalPromiseModule() { - angular.module('app/visualize/Promise', []).service('Promise', PromiseServiceCreator); -} - -function createLocalPrivateModule() { - angular.module('app/visualize/Private', []).provider('Private', PrivateProvider); -} - function createLocalTopNavModule(navigation: NavigationStart) { angular .module('app/visualize/TopNav', ['react']) diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js index 1fab38027f65b..7d1c29fbf48da 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js @@ -30,7 +30,7 @@ import { VisualizeConstants } from '../visualize_constants'; import { getEditBreadcrumbs } from '../breadcrumbs'; import { addHelpMenuToAppChrome } from '../help_menu/help_menu_util'; -import { unhashUrl } from '../../../../../../../plugins/kibana_utils/public'; +import { unhashUrl, removeQueryParam } from '../../../../../../../plugins/kibana_utils/public'; import { MarkdownSimple, toMountPoint } from '../../../../../../../plugins/kibana_react/public'; import { addFatalError, kbnBaseUrl } from '../../../../../../../plugins/kibana_legacy/public'; import { @@ -69,16 +69,7 @@ export function initEditorDirective(app, deps) { initVisualizationDirective(app, deps); } -function VisualizeAppController( - $scope, - $route, - $window, - $injector, - $timeout, - kbnUrl, - kbnUrlStateStorage, - history -) { +function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlStateStorage, history) { const { indexPatterns, localStorage, @@ -421,7 +412,7 @@ function VisualizeAppController( const addToDashMode = $route.current.params[DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM]; - kbnUrl.removeParam(DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM); + removeQueryParam(history, DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM); $scope.isAddToDashMode = () => addToDashMode; @@ -639,10 +630,10 @@ function VisualizeAppController( const savedVisualizationParsedUrl = new KibanaParsedUrl({ basePath: getBasePath(), appId: kbnBaseUrl.slice('/app/'.length), - appPath: kbnUrl.eval(`${VisualizeConstants.EDIT_PATH}/{{id}}`, { id: savedVis.id }), + appPath: `${VisualizeConstants.EDIT_PATH}/${encodeURIComponent(savedVis.id)}`, }); // Manually insert a new url so the back button will open the saved visualization. - $window.history.pushState({}, '', savedVisualizationParsedUrl.getRootRelativePath()); + history.replace(savedVisualizationParsedUrl.appPath); setActiveUrl(savedVisualizationParsedUrl.appPath); const lastDashboardAbsoluteUrl = chrome.navLinks.get('kibana:dashboard').url; @@ -658,7 +649,7 @@ function VisualizeAppController( DashboardConstants.ADD_EMBEDDABLE_ID, savedVis.id ); - kbnUrl.change(dashboardParsedUrl.appPath); + history.push(dashboardParsedUrl.appPath); } else if (savedVis.id === $route.current.params.id) { chrome.docTitle.change(savedVis.lastSavedTitle); chrome.setBreadcrumbs($injector.invoke(getEditBreadcrumbs)); diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing.js index 5a479a491395a..6c02afb672e4c 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing.js @@ -34,7 +34,7 @@ export function initListingDirective(app) { ); } -export function VisualizeListingController($injector, $scope, createNewVis, kbnUrlStateStorage) { +export function VisualizeListingController($scope, createNewVis, kbnUrlStateStorage, history) { const { addBasePath, chrome, @@ -46,7 +46,6 @@ export function VisualizeListingController($injector, $scope, createNewVis, kbnU visualizations, core: { docLinks, savedObjects }, } = getServices(); - const kbnUrl = $injector.get('kbnUrl'); // syncs `_g` portion of url with query services const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl( @@ -83,7 +82,11 @@ export function VisualizeListingController($injector, $scope, createNewVis, kbnU this.closeNewVisModal = visualizations.showNewVisModal({ onClose: () => { // In case the user came via a URL to this page, change the URL to the regular landing page URL after closing the modal - kbnUrl.changePath(VisualizeConstants.LANDING_PAGE_PATH); + history.push({ + // Should preserve querystring part so the global state is preserved. + ...history.location, + pathname: VisualizeConstants.LANDING_PAGE_PATH, + }); }, }); } diff --git a/src/plugins/kibana_legacy/public/angular/angular_config.tsx b/src/plugins/kibana_legacy/public/angular/angular_config.tsx index 67d62cab7409b..71cd57ef2d72e 100644 --- a/src/plugins/kibana_legacy/public/angular/angular_config.tsx +++ b/src/plugins/kibana_legacy/public/angular/angular_config.tsx @@ -226,7 +226,7 @@ const $setupUICapabilityRedirect = (newPlatform: CoreStart) => ( } if (!get(newPlatform.application.capabilities, route.requireUICapability)) { - $injector.get('kbnUrl').change('/home'); + $injector.get('$location').url('/home'); event.preventDefault(); } } From 7bafeb1d6f562a7e6b3c893ad7fc51b0a2e5de6a Mon Sep 17 00:00:00 2001 From: Tudor Golubenco Date: Mon, 23 Mar 2020 10:29:38 +0100 Subject: [PATCH 002/179] [SIEM] Use ECS categorisation for Authentication widgets (#60734) * Update the Authentication histogram to use categorization fields * linting * Use categorization fields for the Authentications table * Use event.outcome for authentications KPIs * Adjust mock to fix unit test Co-authored-by: Elastic Machine --- .../navigation/authentications_query_tab_body.tsx | 10 +++++----- .../siem/server/lib/authentications/query.dsl.ts | 4 ++-- .../plugins/siem/server/lib/kpi_hosts/mock.ts | 8 ++++---- .../lib/kpi_hosts/query_authentication.dsl.ts | 8 ++++---- .../query.authentications_over_time.dsl.ts | 15 +++++++++++++-- 5 files changed, 28 insertions(+), 17 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/authentications_query_tab_body.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/authentications_query_tab_body.tsx index fb083b7a7da2f..5a6759fd07221 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/authentications_query_tab_body.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/authentications_query_tab_body.tsx @@ -25,15 +25,15 @@ const AuthenticationTableManage = manageQuery(AuthenticationTable); const ID = 'authenticationsOverTimeQuery'; const authStackByOptions: MatrixHistogramOption[] = [ { - text: 'event.type', - value: 'event.type', + text: 'event.outcome', + value: 'event.outcome', }, ]; -const DEFAULT_STACK_BY = 'event.type'; +const DEFAULT_STACK_BY = 'event.outcome'; enum AuthMatrixDataGroup { - authSuccess = 'authentication_success', - authFailure = 'authentication_failure', + authSuccess = 'success', + authFailure = 'failure', } export const authMatrixDataMappingFields: MatrixHistogramMappingTypes = { diff --git a/x-pack/legacy/plugins/siem/server/lib/authentications/query.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/authentications/query.dsl.ts index 333cc79fadabc..b9ed88e91f87d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/authentications/query.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/authentications/query.dsl.ts @@ -70,7 +70,7 @@ export const buildQuery = ({ failures: { filter: { term: { - 'event.type': 'authentication_failure', + 'event.outcome': 'failure', }, }, aggs: { @@ -86,7 +86,7 @@ export const buildQuery = ({ successes: { filter: { term: { - 'event.type': 'authentication_success', + 'event.outcome': 'success', }, }, aggs: { diff --git a/x-pack/legacy/plugins/siem/server/lib/kpi_hosts/mock.ts b/x-pack/legacy/plugins/siem/server/lib/kpi_hosts/mock.ts index b82a540900bd0..ed9fbf0ba0646 100644 --- a/x-pack/legacy/plugins/siem/server/lib/kpi_hosts/mock.ts +++ b/x-pack/legacy/plugins/siem/server/lib/kpi_hosts/mock.ts @@ -356,15 +356,15 @@ export const mockKpiHostDetailsUniqueIpsQuery = [ ]; const mockAuthAggs = { - authentication_success: { filter: { term: { 'event.type': 'authentication_success' } } }, + authentication_success: { filter: { term: { 'event.outcome': 'success' } } }, authentication_success_histogram: { auto_date_histogram: { field: '@timestamp', buckets: '6' }, - aggs: { count: { filter: { term: { 'event.type': 'authentication_success' } } } }, + aggs: { count: { filter: { term: { 'event.outcome': 'success' } } } }, }, - authentication_failure: { filter: { term: { 'event.type': 'authentication_failure' } } }, + authentication_failure: { filter: { term: { 'event.outcome': 'failure' } } }, authentication_failure_histogram: { auto_date_histogram: { field: '@timestamp', buckets: '6' }, - aggs: { count: { filter: { term: { 'event.type': 'authentication_failure' } } } }, + aggs: { count: { filter: { term: { 'event.outcome': 'failure' } } } }, }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/kpi_hosts/query_authentication.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/kpi_hosts/query_authentication.dsl.ts index 5734aa6ee88cc..0b7803d007194 100644 --- a/x-pack/legacy/plugins/siem/server/lib/kpi_hosts/query_authentication.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/kpi_hosts/query_authentication.dsl.ts @@ -49,7 +49,7 @@ export const buildAuthQuery = ({ authentication_success: { filter: { term: { - 'event.type': 'authentication_success', + 'event.outcome': 'success', }, }, }, @@ -62,7 +62,7 @@ export const buildAuthQuery = ({ count: { filter: { term: { - 'event.type': 'authentication_success', + 'event.outcome': 'success', }, }, }, @@ -71,7 +71,7 @@ export const buildAuthQuery = ({ authentication_failure: { filter: { term: { - 'event.type': 'authentication_failure', + 'event.outcome': 'failure', }, }, }, @@ -84,7 +84,7 @@ export const buildAuthQuery = ({ count: { filter: { term: { - 'event.type': 'authentication_failure', + 'event.outcome': 'failure', }, }, }, diff --git a/x-pack/legacy/plugins/siem/server/lib/matrix_histogram/query.authentications_over_time.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/matrix_histogram/query.authentications_over_time.dsl.ts index ccf0d235abdd3..34a3804f974de 100644 --- a/x-pack/legacy/plugins/siem/server/lib/matrix_histogram/query.authentications_over_time.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/matrix_histogram/query.authentications_over_time.dsl.ts @@ -13,10 +13,21 @@ export const buildAuthenticationsOverTimeQuery = ({ sourceConfiguration: { fields: { timestamp }, }, - stackByField = 'event.type', + stackByField = 'event.outcome', }: MatrixHistogramRequestOptions) => { const filter = [ ...createQueryFilterClauses(filterQuery), + { + bool: { + must: [ + { + term: { + 'event.category': 'authentication', + }, + }, + ], + }, + }, { range: { [timestamp]: { @@ -45,7 +56,7 @@ export const buildAuthenticationsOverTimeQuery = ({ eventActionGroup: { terms: { field: stackByField, - include: ['authentication_success', 'authentication_failure'], + include: ['success', 'failure'], order: { _count: 'desc', }, From b03a3628dd918bc34d39e39cefe01d1ec8986a82 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Mon, 23 Mar 2020 10:38:51 +0000 Subject: [PATCH 003/179] [ML] Fixing app clean up (#60853) --- x-pack/plugins/ml/public/application/app.tsx | 65 +++++++++----------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 6269c11fca896..8c3e0c066f411 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -23,45 +23,16 @@ type MlDependencies = MlSetupDependencies & MlStartDependencies; interface AppProps { coreStart: CoreStart; deps: MlDependencies; - appMountParams: AppMountParameters; } const localStorage = new Storage(window.localStorage); -const App: FC = ({ coreStart, deps, appMountParams }) => { - setDependencyCache({ - indexPatterns: deps.data.indexPatterns, - timefilter: deps.data.query.timefilter, - fieldFormats: deps.data.fieldFormats, - autocomplete: deps.data.autocomplete, - config: coreStart.uiSettings!, - chrome: coreStart.chrome!, - docLinks: coreStart.docLinks!, - toastNotifications: coreStart.notifications.toasts, - overlays: coreStart.overlays, - recentlyAccessed: coreStart.chrome!.recentlyAccessed, - basePath: coreStart.http.basePath, - savedObjectsClient: coreStart.savedObjects.client, - application: coreStart.application, - http: coreStart.http, - security: deps.security, - urlGenerators: deps.share.urlGenerators, - }); - - const mlLicense = setLicenseCache(deps.licensing); - - appMountParams.onAppLeave(actions => { - mlLicense.unsubscribe(); - clearCache(); - return actions.default(); - }); - +const App: FC = ({ coreStart, deps }) => { const pageDeps = { indexPatterns: deps.data.indexPatterns, config: coreStart.uiSettings!, setBreadcrumbs: coreStart.chrome!.setBreadcrumbs, }; - const services = { appName: 'ML', data: deps.data, @@ -85,10 +56,34 @@ export const renderApp = ( deps: MlDependencies, appMountParams: AppMountParameters ) => { - ReactDOM.render( - , - appMountParams.element - ); + setDependencyCache({ + indexPatterns: deps.data.indexPatterns, + timefilter: deps.data.query.timefilter, + fieldFormats: deps.data.fieldFormats, + autocomplete: deps.data.autocomplete, + config: coreStart.uiSettings!, + chrome: coreStart.chrome!, + docLinks: coreStart.docLinks!, + toastNotifications: coreStart.notifications.toasts, + overlays: coreStart.overlays, + recentlyAccessed: coreStart.chrome!.recentlyAccessed, + basePath: coreStart.http.basePath, + savedObjectsClient: coreStart.savedObjects.client, + application: coreStart.application, + http: coreStart.http, + security: deps.security, + urlGenerators: deps.share.urlGenerators, + }); - return () => ReactDOM.unmountComponentAtNode(appMountParams.element); + const mlLicense = setLicenseCache(deps.licensing); + + appMountParams.onAppLeave(actions => actions.default()); + + ReactDOM.render(, appMountParams.element); + + return () => { + mlLicense.unsubscribe(); + clearCache(); + ReactDOM.unmountComponentAtNode(appMountParams.element); + }; }; From 7eec87954798f9d28ae18c17a5680ac657da71b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Mon, 23 Mar 2020 11:48:58 +0000 Subject: [PATCH 004/179] [APM]Create custom link from Trace summary (#59648) * adding custom links to actions menu * user should have at least gold license to be able to manage custom links * replacing variable for the correspondent value * refactoring license prompt to a shared place * fixing query to return filters that were saved separated by comma * refactoring license prompt to a shared place * fixing query to return filters that were saved separated by comma * adding unit test, splitting value by comma and removing empty ones * adding custom links to actions menu * UI fixes * moving stuff to common * changing flyout texts * refactoring getSelectOption * refactoring getSelectOption * refactoring filter options name * adding preview panel * adding preview panel * fixing test * adding unit test for replace template variables * fixing typo * polishing preview panel * fixing pr comments * fixing pr comments * adding links * fixing unit test * removing servicemap license prompt --- .../app/ServiceMap/PlatinumLicensePrompt.tsx | 74 ------- .../components/app/ServiceMap/index.tsx | 24 +- .../CustomLinkFlyout/Documentation.tsx | 16 ++ .../CustomLinkFlyout/FiltersSection.tsx | 29 +-- .../CustomLinkFlyout/LinkPreview.test.tsx | 51 +++++ .../CustomLinkFlyout/LinkPreview.tsx | 124 +++++++++++ .../CustomLinkFlyout/LinkSection.tsx | 31 ++- .../CustomLinkFlyout/helper.test.ts | 205 ++++++++++++++++++ .../CustomLink/CustomLinkFlyout/helper.ts | 116 ++++++++-- .../CustomLink/CustomLinkFlyout/index.tsx | 18 +- .../CustomLinkFlyout/saveCustomLink.ts | 2 +- .../CustomLink.test.tsx => index.test.tsx} | 177 ++++++++++++--- .../Settings/CustomizeUI/CustomLink/index.tsx | 31 ++- .../LicensePrompt/LicensePrompt.stories.tsx} | 6 +- .../components/shared/LicensePrompt/index.tsx | 63 ++++++ .../shared/Links/ElasticDocsLink.tsx | 2 +- .../components/shared/LoadingStatePrompt.tsx | 2 +- .../CustomLink/CustomLinkPopover.test.tsx | 70 ++++++ .../CustomLink/CustomLinkPopover.tsx | 73 +++++++ .../CustomLink/CustomLinkSection.test.tsx | 41 ++++ .../CustomLink/CustomLinkSection.tsx | 40 ++++ .../CustomLink/ManageCustomLink.test.tsx | 50 +++++ .../CustomLink/ManageCustomLink.tsx | 59 +++++ .../CustomLink/index.test.tsx | 128 +++++++++++ .../CustomLink/index.tsx | 128 +++++++++++ .../TransactionActionMenu.tsx | 158 +++++++++++--- .../__test__/TransactionActionMenu.test.tsx | 131 ++++++++++- .../apm/common/custom_link_filter_options.ts | 28 +++ .../lib/helpers/create_or_update_index.ts | 1 + .../get_transaction.test.ts.snap | 73 +++++++ .../list_custom_links.test.ts.snap | 14 ++ .../__test__/get_transaction.test.ts | 56 +++++ .../custom_link/create_custom_link_index.ts | 8 +- .../create_or_update_custom_link.ts | 4 +- .../custom_link/custom_link_types.d.ts | 2 +- .../settings/custom_link/get_transaction.ts | 38 ++++ .../settings/custom_link/list_custom_links.ts | 11 +- .../apm/server/routes/create_apm_api.ts | 6 +- .../apm/server/routes/settings/custom_link.ts | 34 ++- .../typings/es_schemas/raw/fields/service.ts | 1 + 40 files changed, 1894 insertions(+), 231 deletions(-) delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/PlatinumLicensePrompt.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/Documentation.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.test.ts rename x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/{__test__/CustomLink.test.tsx => index.test.tsx} (56%) rename x-pack/legacy/plugins/apm/public/components/{app/ServiceMap/PlatinumLicensePrompt.stories.tsx => shared/LicensePrompt/LicensePrompt.stories.tsx} (79%) create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/LicensePrompt/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.test.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.test.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.test.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.test.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx create mode 100644 x-pack/plugins/apm/common/custom_link_filter_options.ts create mode 100644 x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/get_transaction.test.ts.snap create mode 100644 x-pack/plugins/apm/server/lib/settings/custom_link/__test__/get_transaction.test.ts create mode 100644 x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/PlatinumLicensePrompt.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/PlatinumLicensePrompt.tsx deleted file mode 100644 index 77f0b64ba0fb1..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/PlatinumLicensePrompt.tsx +++ /dev/null @@ -1,74 +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 { - EuiButton, - EuiPanel, - EuiFlexGroup, - EuiFlexItem, - EuiTitle, - EuiText, - EuiSpacer -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { invalidLicenseMessage } from '../../../../../../../plugins/apm/common/service_map'; -import { useKibanaUrl } from '../../../hooks/useKibanaUrl'; - -export function PlatinumLicensePrompt() { - // Set the height to give it some top margin - const flexGroupStyle = { height: '60vh' }; - const flexItemStyle = { width: 600, textAlign: 'center' as const }; - - const licensePageUrl = useKibanaUrl( - '/app/kibana', - '/management/elasticsearch/license_management/home' - ); - - return ( - - - - - - -

- {i18n.translate('xpack.apm.serviceMap.licensePromptTitle', { - defaultMessage: 'Service maps is available in Platinum.' - })} -

-
- - -

{invalidLicenseMessage}

-
- - - {i18n.translate('xpack.apm.serviceMap.licensePromptButtonText', { - defaultMessage: 'Start 30-day Platinum trial' - })} - -
-
-
-
- ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx index 5770771e01905..4974553f6ca93 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -4,21 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import theme from '@elastic/eui/dist/eui_theme_light.json'; import React from 'react'; -import { isValidPlatinumLicense } from '../../../../../../../plugins/apm/common/service_map'; +import { + invalidLicenseMessage, + isValidPlatinumLicense +} from '../../../../../../../plugins/apm/common/service_map'; import { useFetcher } from '../../../hooks/useFetcher'; import { useLicense } from '../../../hooks/useLicense'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { callApmApi } from '../../../services/rest/createCallApmApi'; -import { BetaBadge } from './BetaBadge'; +import { LicensePrompt } from '../../shared/LicensePrompt'; import { Controls } from './Controls'; import { Cytoscape } from './Cytoscape'; import { cytoscapeDivStyle } from './cytoscapeOptions'; import { EmptyBanner } from './EmptyBanner'; -import { PlatinumLicensePrompt } from './PlatinumLicensePrompt'; import { Popover } from './Popover'; import { useRefDimensions } from './useRefDimensions'; +import { BetaBadge } from './BetaBadge'; interface ServiceMapProps { serviceName?: string; @@ -74,6 +78,18 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { ) : ( - + + + + + ); } diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/Documentation.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/Documentation.tsx new file mode 100644 index 0000000000000..48a0288f11ae5 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/Documentation.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { ElasticDocsLink } from '../../../../../shared/Links/ElasticDocsLink'; + +interface Props { + label: string; +} +export const Documentation = ({ label }: Props) => ( + + {label} + +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx index 69fecf25f5143..1c253b2fa8bff 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx @@ -16,12 +16,11 @@ import { import { i18n } from '@kbn/i18n'; import { isEmpty } from 'lodash'; import React from 'react'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { FilterOptions } from '../../../../../../../../../../plugins/apm/server/routes/settings/custom_link'; +import { FilterOptions } from '../../../../../../../../../../plugins/apm/common/custom_link_filter_options'; import { DEFAULT_OPTION, - Filters, - filterSelectOptions, + FilterKeyValue, + FILTER_SELECT_OPTIONS, getSelectOptions } from './helper'; @@ -29,10 +28,10 @@ export const FiltersSection = ({ filters, onChangeFilters }: { - filters: Filters; - onChangeFilters: (filters: Filters) => void; + filters: FilterKeyValue[]; + onChangeFilters: (filters: FilterKeyValue[]) => void; }) => { - const onChangeFilter = (filter: Filters[0], idx: number) => { + const onChangeFilter = (filter: FilterKeyValue, idx: number) => { const newFilters = [...filters]; newFilters[idx] = filter; onChangeFilters(newFilters); @@ -40,7 +39,8 @@ export const FiltersSection = ({ const onRemoveFilter = (idx: number) => { // remove without mutating original array - const newFilters = [...filters].splice(idx, 1); + const newFilters = [...filters]; + newFilters.splice(idx, 1); // if there is only one item left it should not be removed // but reset to empty @@ -68,12 +68,12 @@ export const FiltersSection = ({ - + {i18n.translate( 'xpack.apm.settings.customizeUI.customLink.flyout.filters.subtitle', { defaultMessage: - 'Add additional values within the same field by comma separating values.' + 'Use the filter options to scope them to only appear for specific services.' } )} @@ -83,12 +83,12 @@ export const FiltersSection = ({ {filters.map((filter, idx) => { const [key, value] = filter; const filterId = `filter-${idx}`; - const selectOptions = getSelectOptions(filters, idx); + const selectOptions = getSelectOptions(filters, key); return ( onRemoveFilter(idx)} - disabled={!key && filters.length === 1} + disabled={!value && !key && filters.length === 1} /> @@ -139,7 +140,7 @@ export const FiltersSection = ({ ); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx new file mode 100644 index 0000000000000..9b487cf916089 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx @@ -0,0 +1,51 @@ +/* + * 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 from 'react'; +import { LinkPreview } from '../CustomLinkFlyout/LinkPreview'; +import { render, getNodeText, getByTestId } from '@testing-library/react'; + +describe('LinkPreview', () => { + const getElementValue = (container: HTMLElement, id: string) => + getNodeText( + ((getByTestId(container, id) as HTMLDivElement) + .children as HTMLCollection)[0] as HTMLDivElement + ); + + it('shows label and url default values', () => { + const { container } = render( + + ); + expect(getElementValue(container, 'preview-label')).toEqual('Elastic.co'); + expect(getElementValue(container, 'preview-url')).toEqual( + 'https://www.elastic.co' + ); + }); + + it('shows label and url values', () => { + const { container } = render( + + ); + expect(getElementValue(container, 'preview-label')).toEqual('foo'); + expect( + (getByTestId(container, 'preview-link') as HTMLAnchorElement).text + ).toEqual('https://baz.co'); + }); + + it('shows warning when couldnt replace context variables', () => { + const { container } = render( + + ); + expect(getElementValue(container, 'preview-label')).toEqual('foo'); + expect( + (getByTestId(container, 'preview-link') as HTMLAnchorElement).text + ).toEqual('https://baz.co?service.name={{invalid}'); + expect(getByTestId(container, 'preview-warning')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx new file mode 100644 index 0000000000000..0ad3455ab271f --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx @@ -0,0 +1,124 @@ +/* + * 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, { useEffect, useState } from 'react'; +import { + EuiPanel, + EuiText, + EuiSpacer, + EuiLink, + EuiToolTip, + EuiIcon, + EuiFlexGroup, + EuiFlexItem +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { debounce } from 'lodash'; +import { Transaction } from '../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { callApmApi } from '../../../../../../services/rest/createCallApmApi'; +import { + FilterKeyValue, + convertFiltersToObject, + replaceTemplateVariables +} from './helper'; + +interface Props { + label: string; + url: string; + filters: FilterKeyValue[]; +} + +const fetchTransaction = debounce( + async ( + filters: FilterKeyValue[], + callback: (transaction: Transaction) => void + ) => { + const transaction = await callApmApi({ + pathname: '/api/apm/settings/custom_links/transaction', + params: { query: convertFiltersToObject(filters) } + }); + callback(transaction); + }, + 1000 +); + +const getTextColor = (value?: string) => (value ? 'default' : 'subdued'); + +export const LinkPreview = ({ label, url, filters }: Props) => { + const [transaction, setTransaction] = useState(); + + useEffect(() => { + fetchTransaction(filters, setTransaction); + }, [filters]); + + const { formattedUrl, error } = replaceTemplateVariables(url, transaction); + + return ( + + + {label + ? label + : i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.default.label', + { defaultMessage: 'Elastic.co' } + )} + + + + {url ? ( + + {formattedUrl} + + ) : ( + i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.default.url', + { defaultMessage: 'https://www.elastic.co' } + ) + )} + + + + + + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.linkPreview.descrition', + { + defaultMessage: + 'Test your link with values from an example transaction document based on the filters above.' + } + )} + + + + + {error && ( + + + + )} + + + + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx index 89f55a6c682ca..8bcebc2aea09e 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx @@ -13,11 +13,12 @@ import { import { i18n } from '@kbn/i18n'; import React from 'react'; import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { Documentation } from './Documentation'; interface InputField { name: keyof CustomLink; label: string; - helpText: string; + helpText: string | React.ReactNode; placeholder: string; onChange: (value: string) => void; value?: string; @@ -69,13 +70,25 @@ export const LinkSection = ({ defaultMessage: 'URL' } ), - helpText: i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.flyout.link.url.helpText', - { - defaultMessage: - 'Add fieldname variables to your URL to apply values e.g. {sample}. TODO: Learn more in the docs.', - values: { sample: '{{trace.id}}' } - } + helpText: ( + <> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.link.url.helpText', + { + defaultMessage: + 'Add field name variables to your URL to apply values e.g. {sample}.', + values: { sample: '{{trace.id}}' } + } + )}{' '} + + ), placeholder: i18n.translate( 'xpack.apm.settings.customizeUI.customLink.flyout.link.url.placeholder', @@ -125,7 +138,7 @@ export const LinkSection = ({ fullWidth value={field.value} onChange={e => field.onChange(e.target.value)} - aria-label={field.name} + data-test-subj={field.name} /> ); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.test.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.test.ts new file mode 100644 index 0000000000000..ac01ee48f2fe5 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.test.ts @@ -0,0 +1,205 @@ +/* + * 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 { + convertFiltersToArray, + convertFiltersToObject, + getSelectOptions, + replaceTemplateVariables +} from '../CustomLinkFlyout/helper'; +import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { Transaction } from '../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; + +describe('Custom link helper', () => { + describe('convertFiltersToArray', () => { + it('returns array of tuple when custom link not defined', () => { + expect(convertFiltersToArray()).toEqual([['', '']]); + }); + it('returns filters as array', () => { + expect( + convertFiltersToArray({ + 'service.name': 'foo', + 'transaction.type': 'bar' + } as CustomLink) + ).toEqual([ + ['service.name', 'foo'], + ['transaction.type', 'bar'] + ]); + }); + it('returns empty when no filter is added', () => { + expect( + convertFiltersToArray({ + label: 'foo', + url: 'bar' + } as CustomLink) + ).toEqual([['', '']]); + }); + }); + + describe('convertFiltersToObject', () => { + it('returns undefined when any filter is added', () => { + expect(convertFiltersToObject([['', '']])).toBeUndefined(); + }); + it('removes uncompleted filters', () => { + expect( + convertFiltersToObject([ + ['service.name', ''], + ['', 'foo'], + ['transaction.type', 'bar'] + ]) + ).toEqual({ 'transaction.type': ['bar'] }); + }); + it('splits the value by comma', () => { + expect( + convertFiltersToObject([ + ['service.name', 'foo'], + ['service.environment', 'foo, bar'], + ['transaction.type', 'foo, '], + ['transaction.name', 'foo,'] + ]) + ).toEqual({ + 'service.name': ['foo'], + 'service.environment': ['foo', 'bar'], + 'transaction.type': ['foo'], + 'transaction.name': ['foo'] + }); + }); + }); + + describe('getSelectOptions', () => { + it('returns all available options when no filters were selected', () => { + expect( + getSelectOptions( + [ + ['', ''], + ['', ''], + ['', ''], + ['', ''] + ], + '' + ) + ).toEqual([ + { value: 'DEFAULT', text: 'Select field...' }, + { value: 'service.name', text: 'service.name' }, + { value: 'service.environment', text: 'service.environment' }, + { value: 'transaction.type', text: 'transaction.type' }, + { value: 'transaction.name', text: 'transaction.name' } + ]); + }); + it('removes item added in another filter', () => { + expect( + getSelectOptions( + [ + ['service.name', 'foo'], + ['', ''], + ['', ''], + ['', ''] + ], + '' + ) + ).toEqual([ + { value: 'DEFAULT', text: 'Select field...' }, + { value: 'service.environment', text: 'service.environment' }, + { value: 'transaction.type', text: 'transaction.type' }, + { value: 'transaction.name', text: 'transaction.name' } + ]); + }); + it('removes item added in another filter but keep the current selected', () => { + expect( + getSelectOptions( + [ + ['service.name', 'foo'], + ['transaction.name', 'bar'], + ['', ''], + ['', ''] + ], + 'transaction.name' + ) + ).toEqual([ + { value: 'DEFAULT', text: 'Select field...' }, + { value: 'service.environment', text: 'service.environment' }, + { value: 'transaction.type', text: 'transaction.type' }, + { value: 'transaction.name', text: 'transaction.name' } + ]); + }); + it('returns empty when all option were selected', () => { + expect( + getSelectOptions( + [ + ['service.name', 'foo'], + ['transaction.name', 'bar'], + ['service.environment', 'baz'], + ['transaction.type', 'qux'] + ], + '' + ) + ).toEqual([{ value: 'DEFAULT', text: 'Select field...' }]); + }); + }); + + describe('replaceTemplateVariables', () => { + const transaction = ({ + service: { name: 'foo' }, + trace: { id: '123' } + } as unknown) as Transaction; + + it('replaces template variables', () => { + expect( + replaceTemplateVariables( + 'https://elastic.co?service.name={{service.name}}&trace.id={{trace.id}}', + transaction + ) + ).toEqual({ + error: undefined, + formattedUrl: 'https://elastic.co?service.name=foo&trace.id=123' + }); + }); + + it('returns error when transaction is not defined', () => { + const expectedResult = { + error: + "We couldn't find a matching transaction document based on the defined filters.", + formattedUrl: 'https://elastic.co?service.name=&trace.id=' + }; + expect( + replaceTemplateVariables( + 'https://elastic.co?service.name={{service.name}}&trace.id={{trace.id}}' + ) + ).toEqual(expectedResult); + expect( + replaceTemplateVariables( + 'https://elastic.co?service.name={{service.name}}&trace.id={{trace.id}}', + ({} as unknown) as Transaction + ) + ).toEqual(expectedResult); + }); + + it('returns error when could not replace variables', () => { + expect( + replaceTemplateVariables( + 'https://elastic.co?service.name={{service.nam}}&trace.id={{trace.i}}', + transaction + ) + ).toEqual({ + error: + "We couldn't find a value match for {{service.nam}}, {{trace.i}} in the example transaction document.", + formattedUrl: 'https://elastic.co?service.name=&trace.id=' + }); + }); + + it('returns error when variable is invalid', () => { + expect( + replaceTemplateVariables( + 'https://elastic.co?service.name={{service.name}', + transaction + ) + ).toEqual({ + error: + "We couldn't find an example transaction document due to invalid variable(s) defined.", + formattedUrl: 'https://elastic.co?service.name={{service.name}' + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts index bb86a251594ab..df99c82c71b70 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts @@ -4,15 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import { isEmpty, pick } from 'lodash'; +import Mustache from 'mustache'; +import { isEmpty, pick, get } from 'lodash'; +import { Transaction } from '../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; import { FilterOptions, - filterOptions - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../../../../../../plugins/apm/server/routes/settings/custom_link'; + FILTER_OPTIONS +} from '../../../../../../../../../../plugins/apm/common/custom_link_filter_options'; import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; -export type Filters = Array<[keyof FilterOptions | '', string]>; +type FilterKey = keyof FilterOptions | ''; +type FilterValue = string; +export type FilterKeyValue = [FilterKey, FilterValue]; interface FilterSelectOption { value: 'DEFAULT' | keyof FilterOptions; @@ -33,9 +36,13 @@ interface FilterSelectOption { * results: [['service.name', 'opbeans-java'],['transaction.type', 'request']] * @param customLink */ -export const convertFiltersToArray = (customLink?: CustomLink): Filters => { +export const convertFiltersToArray = ( + customLink?: CustomLink +): FilterKeyValue[] => { if (customLink) { - const filters = Object.entries(pick(customLink, filterOptions)) as Filters; + const filters = Object.entries( + pick(customLink, FILTER_OPTIONS) + ) as FilterKeyValue[]; if (!isEmpty(filters)) { return filters; } @@ -54,9 +61,18 @@ export const convertFiltersToArray = (customLink?: CustomLink): Filters => { * } * @param filters */ -export const convertFiltersToObject = (filters: Filters) => { +export const convertFiltersToObject = (filters: FilterKeyValue[]) => { const convertedFilters = Object.fromEntries( - filters.filter(([key, value]) => !isEmpty(key) && !isEmpty(value)) + filters + .filter(([key, value]) => !isEmpty(key) && !isEmpty(value)) + .map(([key, value]) => [ + key, + // Splits the value by comma, removes whitespace from both ends and filters out empty values + value + .split(',') + .map(v => v.trim()) + .filter(v => v) + ]) ); if (!isEmpty(convertedFilters)) { return convertedFilters; @@ -71,9 +87,9 @@ export const DEFAULT_OPTION: FilterSelectOption = { ) }; -export const filterSelectOptions: FilterSelectOption[] = [ +export const FILTER_SELECT_OPTIONS: FilterSelectOption[] = [ DEFAULT_OPTION, - ...filterOptions.map(filter => ({ + ...FILTER_OPTIONS.map(filter => ({ value: filter as keyof FilterOptions, text: filter })) @@ -83,14 +99,76 @@ export const filterSelectOptions: FilterSelectOption[] = [ * Returns the options available, removing filters already added, but keeping the selected filter. * * @param filters - * @param idx + * @param selectedKey */ -export const getSelectOptions = (filters: Filters, idx: number) => { - return filterSelectOptions.filter(option => { - const indexUsedFilter = filters.findIndex( - filter => filter[0] === option.value +export const getSelectOptions = ( + filters: FilterKeyValue[], + selectedKey: FilterKey +) => { + return FILTER_SELECT_OPTIONS.filter( + ({ value }) => + !filters.some( + ([filterKey]) => filterKey === value && filterKey !== selectedKey + ) + ); +}; + +const getInvalidTemplateVariables = ( + template: string, + transaction: Transaction +) => { + return (Mustache.parse(template) as Array<[string, string]>) + .filter(([type]) => type === 'name') + .map(([, value]) => value) + .filter(templateVar => get(transaction, templateVar) == null); +}; + +const validateUrl = (url: string, transaction?: Transaction) => { + if (!transaction || isEmpty(transaction)) { + return i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.preview.transaction.notFound', + { + defaultMessage: + "We couldn't find a matching transaction document based on the defined filters." + } + ); + } + try { + const invalidVariables = getInvalidTemplateVariables(url, transaction); + if (!isEmpty(invalidVariables)) { + return i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.preview.contextVariable.noMatch', + { + defaultMessage: + "We couldn't find a value match for {variables} in the example transaction document.", + values: { + variables: invalidVariables + .map(variable => `{{${variable}}}`) + .join(', ') + } + } + ); + } + } catch (e) { + return i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.preview.contextVariable.invalid', + { + defaultMessage: + "We couldn't find an example transaction document due to invalid variable(s) defined." + } ); - // Filter out all items already added, besides the one selected in the current filter. - return indexUsedFilter === -1 || idx === indexUsedFilter; - }); + } +}; + +export const replaceTemplateVariables = ( + url: string, + transaction?: Transaction +) => { + const error = validateUrl(url, transaction); + try { + return { formattedUrl: Mustache.render(url, transaction), error }; + } catch (e) { + // errors will be caught on validateUrl function + return { formattedUrl: url, error }; + } }; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx index 88358c888160b..68755bad5f652 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx @@ -21,6 +21,8 @@ import { FlyoutFooter } from './FlyoutFooter'; import { LinkSection } from './LinkSection'; import { saveCustomLink } from './saveCustomLink'; import { convertFiltersToArray, convertFiltersToObject } from './helper'; +import { LinkPreview } from './LinkPreview'; +import { Documentation } from './Documentation'; interface Props { onClose: () => void; @@ -87,9 +89,17 @@ export const CustomLinkFlyout = ({ 'xpack.apm.settings.customizeUI.customLink.flyout.label', { defaultMessage: - 'Links will be available in the context of transaction details throughout the APM app. You can create an unlimited number of links and use the filter options to scope them to only appear for specific services. You can refer to dynamic variables by using any of the transaction metadata to fill in your URLs. TODO: Learn more about it in the docs.' + 'Links will be available in the context of transaction details throughout the APM app. You can create an unlimited number of links. You can refer to dynamic variables by using any of the transaction metadata to fill in your URLs. More information, including examples, are available in the' } - )} + )}{' '} +

@@ -105,6 +115,10 @@ export const CustomLinkFlyout = ({ + + + + { + let callApmApiSpy: Function; + beforeAll(() => { + callApmApiSpy = spyOn(apmApi, 'callApmApi').and.returnValue({}); + }); + afterAll(() => { + jest.resetAllMocks(); + }); + const goldLicense = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'gold', + status: 'active', + type: 'gold', + uid: '1' + } + }); describe('empty prompt', () => { beforeAll(() => { spyOn(hooks, 'useFetcher').and.returnValue({ @@ -44,14 +64,20 @@ describe('CustomLink', () => { jest.clearAllMocks(); }); it('shows when no link is available', () => { - const component = render(); + const component = render( + + + + ); expectTextsInDocument(component, ['No links found.']); }); it('opens flyout when click to create new link', () => { const { queryByText, getByText } = render( - - - + + + + + ); expect(queryByText('Create link')).not.toBeInTheDocument(); act(() => { @@ -75,9 +101,11 @@ describe('CustomLink', () => { it('shows a table with all custom link', () => { const component = render( - - - + + + + + ); expectTextsInDocument(component, [ 'label 1', @@ -89,9 +117,11 @@ describe('CustomLink', () => { it('checks if create custom link button is available and working', () => { const { queryByText, getByText } = render( - - - + + + + + ); expect(queryByText('Create link')).not.toBeInTheDocument(); act(() => { @@ -103,10 +133,8 @@ describe('CustomLink', () => { describe('Flyout', () => { const refetch = jest.fn(); - let callApmApiSpy: Function; let saveCustomLinkSpy: Function; beforeAll(() => { - callApmApiSpy = spyOn(apmApi, 'callApmApi'); saveCustomLinkSpy = spyOn(saveCustomLink, 'saveCustomLink'); spyOn(hooks, 'useFetcher').and.returnValue({ data, @@ -120,9 +148,11 @@ describe('CustomLink', () => { const openFlyout = () => { const component = render( - - - + + + + + ); expect(component.queryByText('Create link')).not.toBeInTheDocument(); act(() => { @@ -134,13 +164,13 @@ describe('CustomLink', () => { it('creates a custom link', async () => { const component = openFlyout(); - const labelInput = component.getByLabelText('label'); + const labelInput = component.getByTestId('label'); act(() => { fireEvent.change(labelInput, { target: { value: 'foo' } }); }); - const urlInput = component.getByLabelText('url'); + const urlInput = component.getByTestId('url'); act(() => { fireEvent.change(urlInput, { target: { value: 'bar' } @@ -154,9 +184,11 @@ describe('CustomLink', () => { it('deletes a custom link', async () => { const component = render( - - - + + + + + ); expect(component.queryByText('Create link')).not.toBeInTheDocument(); const editButtons = component.getAllByLabelText('Edit'); @@ -204,9 +236,7 @@ describe('CustomLink', () => { if (addNewFilter) { addFilterField(component, 1); } - const field = component.getByLabelText( - fieldName - ) as HTMLSelectElement; + const field = component.getByTestId(fieldName) as HTMLSelectElement; const optionsAvailable = Object.values(field) .map(option => (option as HTMLOptionElement).text) .filter(option => option); @@ -248,4 +278,93 @@ describe('CustomLink', () => { }); }); }); + + describe('invalid license', () => { + beforeAll(() => { + spyOn(hooks, 'useFetcher').and.returnValue({ + data: [], + status: 'success' + }); + }); + it('shows license prompt when user has a basic license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'basic', + status: 'active', + type: 'basic', + uid: '1' + } + }); + const component = render( + + + + + + ); + expectTextsInDocument(component, ['Start free 30-day trial']); + }); + it('shows license prompt when user has an invalid gold license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'gold', + status: 'invalid', + type: 'gold', + uid: '1' + } + }); + const component = render( + + + + + + ); + expectTextsInDocument(component, ['Start free 30-day trial']); + }); + it('shows license prompt when user has an invalid trial license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'trial', + status: 'invalid', + type: 'trial', + uid: '1' + } + }); + const component = render( + + + + + + ); + expectTextsInDocument(component, ['Start free 30-day trial']); + }); + it('doesnt show license prompt when user has a trial license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'trial', + status: 'active', + type: 'trial', + uid: '1' + } + }); + const component = render( + + + + + + ); + expectTextsNotInDocument(component, ['Start free 30-day trial']); + }); + }); }); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx index bc1882c8c2785..a4985d4410699 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx @@ -7,6 +7,8 @@ import { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEmpty } from 'lodash'; import React, { useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useLicense } from '../../../../../hooks/useLicense'; import { CustomLink } from '../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; import { useFetcher, FETCH_STATUS } from '../../../../../hooks/useFetcher'; import { CustomLinkFlyout } from './CustomLinkFlyout'; @@ -14,8 +16,12 @@ import { CustomLinkTable } from './CustomLinkTable'; import { EmptyPrompt } from './EmptyPrompt'; import { Title } from './Title'; import { CreateCustomLinkButton } from './CreateCustomLinkButton'; +import { LicensePrompt } from '../../../../shared/LicensePrompt'; export const CustomLinkOverview = () => { + const license = useLicense(); + const hasValidLicense = license?.isActive && license?.hasAtLeast('gold'); + const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); const [customLinkSelected, setCustomLinkSelected] = useState< CustomLink | undefined @@ -65,7 +71,7 @@ export const CustomLinkOverview = () => { </EuiFlexItem> - {!showEmptyPrompt && ( + {hasValidLicense && !showEmptyPrompt && ( <EuiFlexItem> <EuiFlexGroup alignItems="center" justifyContent="flexEnd"> <EuiFlexItem grow={false}> @@ -77,13 +83,24 @@ export const CustomLinkOverview = () => { </EuiFlexGroup> <EuiSpacer size="m" /> - - {showEmptyPrompt ? ( - <EmptyPrompt onCreateCustomLinkClick={onCreateCustomLinkClick} /> + {hasValidLicense ? ( + showEmptyPrompt ? ( + <EmptyPrompt onCreateCustomLinkClick={onCreateCustomLinkClick} /> + ) : ( + <CustomLinkTable + items={customLinks} + onCustomLinkSelected={setCustomLinkSelected} + /> + ) ) : ( - <CustomLinkTable - items={customLinks} - onCustomLinkSelected={setCustomLinkSelected} + <LicensePrompt + text={i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.license.text', + { + defaultMessage: + "To create custom links, you must be subscribed to an Elastic Gold license or above. With it, you'll have the ability to create custom links to improve your workflow when analyzing your services." + } + )} /> )} </EuiPanel> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/PlatinumLicensePrompt.stories.tsx b/x-pack/legacy/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx similarity index 79% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/PlatinumLicensePrompt.stories.tsx rename to x-pack/legacy/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx index 80281c1a0a8fc..010bba7677f00 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/PlatinumLicensePrompt.stories.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx @@ -6,13 +6,13 @@ import { storiesOf } from '@storybook/react'; import React from 'react'; -import { PlatinumLicensePrompt } from './PlatinumLicensePrompt'; import { ApmPluginContext, ApmPluginContextValue } from '../../../context/ApmPluginContext'; +import { LicensePrompt } from '.'; -storiesOf('app/ServiceMap/PlatinumLicensePrompt', module).add( +storiesOf('app/LicensePrompt', module).add( 'example', () => { const contextMock = ({ @@ -21,7 +21,7 @@ storiesOf('app/ServiceMap/PlatinumLicensePrompt', module).add( return ( <ApmPluginContext.Provider value={contextMock}> - <PlatinumLicensePrompt /> + <LicensePrompt text="To create Feature name, you must be subscribed to an Elastic X license or above." /> </ApmPluginContext.Provider> ); }, diff --git a/x-pack/legacy/plugins/apm/public/components/shared/LicensePrompt/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/LicensePrompt/index.tsx new file mode 100644 index 0000000000000..d2afefb83a568 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/LicensePrompt/index.tsx @@ -0,0 +1,63 @@ +/* + * 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 { EuiButton, EuiEmptyPrompt, EuiPanel } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { useKibanaUrl } from '../../../hooks/useKibanaUrl'; + +interface Props { + text: string; + showBetaBadge?: boolean; +} + +export const LicensePrompt = ({ text, showBetaBadge = false }: Props) => { + const licensePageUrl = useKibanaUrl( + '/app/kibana', + '/management/elasticsearch/license_management/home' + ); + + const renderLicenseBody = ( + <EuiEmptyPrompt + iconType="iInCircle" + iconColor="subdued" + title={ + <h2> + {i18n.translate('xpack.apm.license.title', { + defaultMessage: 'Start free 30-day trial' + })} + </h2> + } + body={<p>{text}</p>} + actions={ + <EuiButton fill={true} href={licensePageUrl}> + {i18n.translate('xpack.apm.license.button', { + defaultMessage: 'Start trial' + })} + </EuiButton> + } + /> + ); + + const renderWithBetaBadge = ( + <EuiPanel + betaBadgeLabel={i18n.translate('xpack.apm.license.betaBadge', { + defaultMessage: 'Beta' + })} + betaBadgeTooltipContent={i18n.translate( + 'xpack.apm.license.betaTooltipMessage', + { + defaultMessage: + 'This feature is currently in beta. If you encounter any bugs or have feedback, please open an issue or visit our discussion forum.' + } + )} + > + {renderLicenseBody} + </EuiPanel> + ); + + return <>{showBetaBadge ? renderWithBetaBadge : renderLicenseBody}</>; +}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx index 0e0c318ad3299..9fcab049e224f 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx @@ -9,7 +9,7 @@ import { EuiLink, EuiLinkAnchorProps } from '@elastic/eui'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; // union type constisting of valid guide sections that we link to -type DocsSection = '/apm/get-started' | '/x-pack' | '/apm/server'; +type DocsSection = '/apm/get-started' | '/x-pack' | '/apm/server' | '/kibana'; interface Props extends EuiLinkAnchorProps { section: DocsSection; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/LoadingStatePrompt.tsx b/x-pack/legacy/plugins/apm/public/components/shared/LoadingStatePrompt.tsx index e1cf07c03dee9..8a87de976f5ed 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/LoadingStatePrompt.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/LoadingStatePrompt.tsx @@ -11,7 +11,7 @@ export function LoadingStatePrompt() { return ( <EuiFlexGroup justifyContent="spaceAround"> <EuiFlexItem grow={false}> - <EuiLoadingSpinner size="l" /> + <EuiLoadingSpinner size="l" data-test-subj="loading-spinner" /> </EuiFlexItem> </EuiFlexGroup> ); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.test.tsx new file mode 100644 index 0000000000000..99789ca2ecdf5 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { CustomLink } from '../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { CustomLinkPopover } from './CustomLinkPopover'; +import { expectTextsInDocument } from '../../../../utils/testHelpers'; + +describe('CustomLinkPopover', () => { + const customLinks = [ + { id: '1', label: 'foo', url: 'http://elastic.co' }, + { + id: '2', + label: 'bar', + url: 'http://elastic.co?service.name={{service.name}}' + } + ] as CustomLink[]; + const transaction = ({ + service: { name: 'foo.bar' } + } as unknown) as Transaction; + it('renders popover', () => { + const component = render( + <CustomLinkPopover + customLinks={customLinks} + transaction={transaction} + onCreateCustomLinkClick={jest.fn()} + onClose={jest.fn()} + /> + ); + expectTextsInDocument(component, ['CUSTOM LINKS', 'Create', 'foo', 'bar']); + }); + + it('closes popover', () => { + const handleCloseMock = jest.fn(); + const { getByText } = render( + <CustomLinkPopover + customLinks={customLinks} + transaction={transaction} + onCreateCustomLinkClick={jest.fn()} + onClose={handleCloseMock} + /> + ); + expect(handleCloseMock).not.toHaveBeenCalled(); + act(() => { + fireEvent.click(getByText('CUSTOM LINKS')); + }); + expect(handleCloseMock).toHaveBeenCalled(); + }); + + it('opens flyout to create new custom link', () => { + const handleCreateCustomLinkClickMock = jest.fn(); + const { getByText } = render( + <CustomLinkPopover + customLinks={customLinks} + transaction={transaction} + onCreateCustomLinkClick={handleCreateCustomLinkClickMock} + onClose={jest.fn()} + /> + ); + expect(handleCreateCustomLinkClickMock).not.toHaveBeenCalled(); + act(() => { + fireEvent.click(getByText('Create')); + }); + expect(handleCreateCustomLinkClickMock).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx new file mode 100644 index 0000000000000..ee4aa25606a0c --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { + EuiPopoverTitle, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { CustomLink } from '../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { CustomLinkSection } from './CustomLinkSection'; +import { ManageCustomLink } from './ManageCustomLink'; +import { px } from '../../../../style/variables'; + +const ScrollableContainer = styled.div` + max-height: ${px(535)}; + overflow: scroll; +`; + +export const CustomLinkPopover = ({ + customLinks, + onCreateCustomLinkClick, + onClose, + transaction +}: { + customLinks: CustomLink[]; + onCreateCustomLinkClick: () => void; + onClose: () => void; + transaction: Transaction; +}) => { + return ( + <> + <EuiPopoverTitle> + <EuiFlexGroup> + <EuiFlexItem style={{ alignItems: 'flex-start' }}> + <EuiButtonEmpty + color="text" + size="xs" + onClick={onClose} + iconType="arrowLeft" + style={{ fontWeight: 'bold' }} + flush="left" + > + {i18n.translate( + 'xpack.apm.transactionActionMenu.customLink.popover.title', + { + defaultMessage: 'CUSTOM LINKS' + } + )} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem> + <ManageCustomLink + onCreateCustomLinkClick={onCreateCustomLinkClick} + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiPopoverTitle> + <ScrollableContainer> + <CustomLinkSection + customLinks={customLinks} + transaction={transaction} + /> + </ScrollableContainer> + </> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.test.tsx new file mode 100644 index 0000000000000..4e52c302c6025 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.test.tsx @@ -0,0 +1,41 @@ +/* + * 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 from 'react'; +import { render } from '@testing-library/react'; +import { CustomLink } from '../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { CustomLinkSection } from './CustomLinkSection'; +import { + expectTextsInDocument, + expectTextsNotInDocument +} from '../../../../utils/testHelpers'; +import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; + +describe('CustomLinkSection', () => { + const customLinks = [ + { id: '1', label: 'foo', url: 'http://elastic.co' }, + { + id: '2', + label: 'bar', + url: 'http://elastic.co?service.name={{service.name}}' + } + ] as CustomLink[]; + const transaction = ({ + service: { name: 'foo.bar' } + } as unknown) as Transaction; + it('shows links', () => { + const component = render( + <CustomLinkSection customLinks={customLinks} transaction={transaction} /> + ); + expectTextsInDocument(component, ['foo', 'bar']); + }); + + it('doesnt show any links', () => { + const component = render( + <CustomLinkSection customLinks={[]} transaction={transaction} /> + ); + expectTextsNotInDocument(component, ['foo', 'bar']); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx new file mode 100644 index 0000000000000..601405dda6ece --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import Mustache from 'mustache'; +import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { CustomLink } from '../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { + SectionLinks, + SectionLink +} from '../../../../../../../../plugins/observability/public'; + +export const CustomLinkSection = ({ + customLinks, + transaction +}: { + customLinks: CustomLink[]; + transaction: Transaction; +}) => ( + <SectionLinks> + {customLinks.map(link => { + let href = link.url; + try { + href = Mustache.render(link.url, transaction); + } catch (e) { + // ignores any error that happens + } + return ( + <SectionLink + key={link.id} + label={link.label} + href={href} + target="_blank" + /> + ); + })} + </SectionLinks> +); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.test.tsx new file mode 100644 index 0000000000000..9e7df53b0882f --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.test.tsx @@ -0,0 +1,50 @@ +/* + * 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 from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { ManageCustomLink } from './ManageCustomLink'; +import { + expectTextsInDocument, + expectTextsNotInDocument +} from '../../../../utils/testHelpers'; + +describe('ManageCustomLink', () => { + it('renders with create button', () => { + const component = render( + <ManageCustomLink onCreateCustomLinkClick={jest.fn()} /> + ); + expect( + component.getByLabelText('Custom links settings page') + ).toBeInTheDocument(); + expectTextsInDocument(component, ['Create']); + }); + it('renders without create button', () => { + const component = render( + <ManageCustomLink + onCreateCustomLinkClick={jest.fn()} + showCreateCustomLinkButton={false} + /> + ); + expect( + component.getByLabelText('Custom links settings page') + ).toBeInTheDocument(); + expectTextsNotInDocument(component, ['Create']); + }); + it('opens flyout to create new custom link', () => { + const handleCreateCustomLinkClickMock = jest.fn(); + const { getByText } = render( + <ManageCustomLink + onCreateCustomLinkClick={handleCreateCustomLinkClickMock} + /> + ); + expect(handleCreateCustomLinkClickMock).not.toHaveBeenCalled(); + act(() => { + fireEvent.click(getByText('Create')); + }); + expect(handleCreateCustomLinkClickMock).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx new file mode 100644 index 0000000000000..fa9f8b2f07c53 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx @@ -0,0 +1,59 @@ +/* + * 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 from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + EuiButtonEmpty, + EuiIcon +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { APMLink } from '../../Links/apm/APMLink'; + +export const ManageCustomLink = ({ + onCreateCustomLinkClick, + showCreateCustomLinkButton = true +}: { + onCreateCustomLinkClick: () => void; + showCreateCustomLinkButton?: boolean; +}) => ( + <EuiFlexGroup> + <EuiFlexItem> + <EuiFlexGroup justifyContent="flexEnd" gutterSize="none"> + <EuiFlexItem grow={false} style={{ justifyContent: 'center' }}> + <EuiToolTip + position="top" + content={i18n.translate('xpack.apm.customLink.buttom.manage', { + defaultMessage: 'Manage custom links' + })} + > + <APMLink path={`/settings/customize-ui`}> + <EuiIcon + type="gear" + color="text" + aria-label="Custom links settings page" + /> + </APMLink> + </EuiToolTip> + </EuiFlexItem> + {showCreateCustomLinkButton && ( + <EuiFlexItem grow={false}> + <EuiButtonEmpty + iconType="plusInCircle" + size="xs" + onClick={onCreateCustomLinkClick} + > + {i18n.translate('xpack.apm.customLink.buttom.create.title', { + defaultMessage: 'Create' + })} + </EuiButtonEmpty> + </EuiFlexItem> + )} + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> +); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.test.tsx new file mode 100644 index 0000000000000..ba9c7eee8792b --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.test.tsx @@ -0,0 +1,128 @@ +/* + * 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 from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { CustomLink } from '.'; +import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { FETCH_STATUS } from '../../../../hooks/useFetcher'; +import { + expectTextsInDocument, + expectTextsNotInDocument +} from '../../../../utils/testHelpers'; +import { CustomLink as CustomLinkType } from '../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; + +describe('Custom links', () => { + it('shows empty message when no custom link is available', () => { + const component = render( + <CustomLink + customLinks={[]} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={jest.fn()} + status={FETCH_STATUS.SUCCESS} + /> + ); + + expectTextsInDocument(component, [ + 'No custom links found. Set up your own custom links i.e. a link to a specific Dashboard or external link.' + ]); + expectTextsNotInDocument(component, ['Create']); + }); + + it('shows loading while custom links are fetched', () => { + const { getByTestId } = render( + <CustomLink + customLinks={[]} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={jest.fn()} + status={FETCH_STATUS.LOADING} + /> + ); + expect(getByTestId('loading-spinner')).toBeInTheDocument(); + }); + + it('shows first 3 custom links available', () => { + const customLinks = [ + { id: '1', label: 'foo', url: 'foo' }, + { id: '2', label: 'bar', url: 'bar' }, + { id: '3', label: 'baz', url: 'baz' }, + { id: '4', label: 'qux', url: 'qux' } + ] as CustomLinkType[]; + const component = render( + <CustomLink + customLinks={customLinks} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={jest.fn()} + status={FETCH_STATUS.SUCCESS} + /> + ); + expectTextsInDocument(component, ['foo', 'bar', 'baz']); + expectTextsNotInDocument(component, ['qux']); + }); + + it('clicks on See more button', () => { + const customLinks = [ + { id: '1', label: 'foo', url: 'foo' }, + { id: '2', label: 'bar', url: 'bar' }, + { id: '3', label: 'baz', url: 'baz' }, + { id: '4', label: 'qux', url: 'qux' } + ] as CustomLinkType[]; + const onSeeMoreClickMock = jest.fn(); + const component = render( + <CustomLink + customLinks={customLinks} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={onSeeMoreClickMock} + status={FETCH_STATUS.SUCCESS} + /> + ); + expect(onSeeMoreClickMock).not.toHaveBeenCalled(); + act(() => { + fireEvent.click(component.getByText('See more')); + }); + expect(onSeeMoreClickMock).toHaveBeenCalled(); + }); + + describe('create custom link buttons', () => { + it('shows create button below empty message', () => { + const component = render( + <CustomLink + customLinks={[]} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={jest.fn()} + status={FETCH_STATUS.SUCCESS} + /> + ); + + expectTextsInDocument(component, ['Create custom link']); + expectTextsNotInDocument(component, ['Create']); + }); + it('shows create button besides the title', () => { + const customLinks = [ + { id: '1', label: 'foo', url: 'foo' }, + { id: '2', label: 'bar', url: 'bar' }, + { id: '3', label: 'baz', url: 'baz' }, + { id: '4', label: 'qux', url: 'qux' } + ] as CustomLinkType[]; + const component = render( + <CustomLink + customLinks={customLinks} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={jest.fn()} + status={FETCH_STATUS.SUCCESS} + /> + ); + expectTextsInDocument(component, ['Create']); + expectTextsNotInDocument(component, ['Create custom link']); + }); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx new file mode 100644 index 0000000000000..9280f8e71bf9e --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx @@ -0,0 +1,128 @@ +/* + * 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 from 'react'; +import { + EuiText, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiButtonEmpty +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import { isEmpty } from 'lodash'; +import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { CustomLink as CustomLinkType } from '../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { + ActionMenuDivider, + SectionSubtitle +} from '../../../../../../../../plugins/observability/public'; +import { CustomLinkSection } from './CustomLinkSection'; +import { ManageCustomLink } from './ManageCustomLink'; +import { FETCH_STATUS } from '../../../../hooks/useFetcher'; +import { LoadingStatePrompt } from '../../LoadingStatePrompt'; +import { px } from '../../../../style/variables'; + +const SeeMoreButton = styled.button<{ show: boolean }>` + display: ${props => (props.show ? 'flex' : 'none')}; + align-items: center; + width: 100%; + justify-content: space-between; + &:hover { + text-decoration: underline; + } +`; + +export const CustomLink = ({ + customLinks, + status, + onCreateCustomLinkClick, + onSeeMoreClick, + transaction +}: { + customLinks: CustomLinkType[]; + status: FETCH_STATUS; + onCreateCustomLinkClick: () => void; + onSeeMoreClick: () => void; + transaction: Transaction; +}) => { + const renderEmptyPrompt = ( + <> + <EuiText size="xs" grow={false} style={{ width: px(300) }}> + {i18n.translate('xpack.apm.customLink.empty', { + defaultMessage: + 'No custom links found. Set up your own custom links i.e. a link to a specific Dashboard or external link.' + })} + </EuiText> + <EuiSpacer size="s" /> + <EuiButtonEmpty + iconType="plusInCircle" + size="xs" + onClick={onCreateCustomLinkClick} + > + {i18n.translate('xpack.apm.customLink.buttom.create', { + defaultMessage: 'Create custom link' + })} + </EuiButtonEmpty> + </> + ); + + const renderCustomLinkBottomSection = isEmpty(customLinks) ? ( + renderEmptyPrompt + ) : ( + <SeeMoreButton onClick={onSeeMoreClick} show={customLinks.length > 3}> + <EuiText size="s"> + {i18n.translate('xpack.apm.transactionActionMenu.customLink.seeMore', { + defaultMessage: 'See more' + })} + </EuiText> + <EuiIcon type="arrowRight" /> + </SeeMoreButton> + ); + + return ( + <> + <ActionMenuDivider /> + <EuiFlexGroup> + <EuiFlexItem style={{ justifyContent: 'center' }}> + <EuiText size={'s'} grow={false}> + <h5> + {i18n.translate( + 'xpack.apm.transactionActionMenu.customLink.section', + { + defaultMessage: 'Custom Links' + } + )} + </h5> + </EuiText> + </EuiFlexItem> + <EuiFlexItem> + <ManageCustomLink + onCreateCustomLinkClick={onCreateCustomLinkClick} + showCreateCustomLinkButton={!!customLinks.length} + /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer size="s" /> + <SectionSubtitle> + {i18n.translate('xpack.apm.transactionActionMenu.customLink.subtitle', { + defaultMessage: 'Links will open in a new window.' + })} + </SectionSubtitle> + <CustomLinkSection + customLinks={customLinks.slice(0, 3)} + transaction={transaction} + /> + <EuiSpacer size="s" /> + {status === FETCH_STATUS.LOADING ? ( + <LoadingStatePrompt /> + ) : ( + renderCustomLinkBottomSection + )} + </> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx index dd022626807d0..e3c412f40ba3a 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx @@ -6,7 +6,10 @@ import { EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { FunctionComponent, useState } from 'react'; +import React, { FunctionComponent, useMemo, useState } from 'react'; +import { FilterOptions } from '../../../../../../../plugins/apm/common/custom_link_filter_options'; +import { CustomLink as CustomLinkType } from '../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types'; +import { Transaction } from '../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; import { ActionMenu, ActionMenuDivider, @@ -16,11 +19,16 @@ import { SectionSubtitle, SectionTitle } from '../../../../../../../plugins/observability/public'; -import { Transaction } from '../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; +import { useFetcher } from '../../../hooks/useFetcher'; import { useLocation } from '../../../hooks/useLocation'; import { useUrlParams } from '../../../hooks/useUrlParams'; +import { CustomLinkFlyout } from '../../app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout'; +import { CustomLink } from './CustomLink'; +import { CustomLinkPopover } from './CustomLink/CustomLinkPopover'; import { getSections } from './sections'; +import { useLicense } from '../../../hooks/useLicense'; +import { px } from '../../../style/variables'; interface Props { readonly transaction: Transaction; @@ -37,11 +45,36 @@ const ActionMenuButton = ({ onClick }: { onClick: () => void }) => ( export const TransactionActionMenu: FunctionComponent<Props> = ({ transaction }: Props) => { + const license = useLicense(); + const hasValidLicense = license?.isActive && license?.hasAtLeast('gold'); + const { core } = useApmPluginContext(); const location = useLocation(); const { urlParams } = useUrlParams(); - const [isOpen, setIsOpen] = useState(false); + const [isActionPopoverOpen, setIsActionPopoverOpen] = useState(false); + const [isCustomLinksPopoverOpen, setIsCustomLinksPopoverOpen] = useState( + false + ); + const [isCustomLinkFlyoutOpen, setIsCustomLinkFlyoutOpen] = useState(false); + + const filters: FilterOptions = useMemo( + () => ({ + 'service.name': transaction?.service.name, + 'service.environment': transaction?.service.environment, + 'transaction.name': transaction?.transaction.name, + 'transaction.type': transaction?.transaction.type + }), + [transaction] + ); + const { data: customLinks = [], status, refetch } = useFetcher( + callApmApi => + callApmApi({ + pathname: '/api/apm/settings/custom_links', + params: { query: filters } + }), + [filters] + ); const sections = getSections({ transaction, @@ -50,39 +83,92 @@ export const TransactionActionMenu: FunctionComponent<Props> = ({ urlParams }); + const toggleCustomLinkFlyout = () => { + setIsCustomLinkFlyoutOpen(isOpen => !isOpen); + }; + + const toggleCustomLinkPopover = () => { + setIsCustomLinksPopoverOpen(isOpen => !isOpen); + }; + return ( - <ActionMenu - id="transactionActionMenu" - closePopover={() => setIsOpen(false)} - isOpen={isOpen} - anchorPosition="downRight" - button={<ActionMenuButton onClick={() => setIsOpen(!isOpen)} />} - > - {sections.map((section, idx) => { - const isLastSection = idx !== sections.length - 1; - return ( - <div key={idx}> - {section.map(item => ( - <Section key={item.key}> - {item.title && <SectionTitle>{item.title}</SectionTitle>} - {item.subtitle && ( - <SectionSubtitle>{item.subtitle}</SectionSubtitle> - )} - <SectionLinks> - {item.actions.map(action => ( - <SectionLink - key={action.key} - label={action.label} - href={action.href} - /> - ))} - </SectionLinks> - </Section> - ))} - {isLastSection && <ActionMenuDivider />} - </div> - ); - })} - </ActionMenu> + <> + {isCustomLinkFlyoutOpen && ( + <CustomLinkFlyout + customLinkSelected={{ ...filters } as CustomLinkType} + onClose={toggleCustomLinkFlyout} + onSave={() => { + toggleCustomLinkFlyout(); + refetch(); + }} + onDelete={() => { + toggleCustomLinkFlyout(); + refetch(); + }} + /> + )} + <ActionMenu + id="transactionActionMenu" + closePopover={() => { + setIsActionPopoverOpen(false); + setIsCustomLinksPopoverOpen(false); + }} + isOpen={isActionPopoverOpen} + anchorPosition="downRight" + button={ + <ActionMenuButton onClick={() => setIsActionPopoverOpen(true)} /> + } + > + <div style={{ maxHeight: px(600) }}> + {isCustomLinksPopoverOpen ? ( + <CustomLinkPopover + customLinks={customLinks.slice(3, customLinks.length)} + onCreateCustomLinkClick={toggleCustomLinkFlyout} + onClose={toggleCustomLinkPopover} + transaction={transaction} + /> + ) : ( + <> + {sections.map((section, idx) => { + const isLastSection = idx !== sections.length - 1; + return ( + <div key={idx}> + {section.map(item => ( + <Section key={item.key}> + {item.title && ( + <SectionTitle>{item.title}</SectionTitle> + )} + {item.subtitle && ( + <SectionSubtitle>{item.subtitle}</SectionSubtitle> + )} + <SectionLinks> + {item.actions.map(action => ( + <SectionLink + key={action.key} + label={action.label} + href={action.href} + /> + ))} + </SectionLinks> + </Section> + ))} + {isLastSection && <ActionMenuDivider />} + </div> + ); + })} + {hasValidLicense && ( + <CustomLink + customLinks={customLinks} + status={status} + onCreateCustomLinkClick={toggleCustomLinkFlyout} + onSeeMoreClick={toggleCustomLinkPopover} + transaction={transaction} + /> + )} + </> + )} + </div> + </ActionMenu> + </> ); }; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx index ac3616e8c134c..9094662e34914 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx @@ -5,11 +5,18 @@ */ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, act } from '@testing-library/react'; import { TransactionActionMenu } from '../TransactionActionMenu'; import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; import * as Transactions from './mockData'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; +import { + MockApmPluginContextWrapper, + expectTextsNotInDocument, + expectTextsInDocument +} from '../../../../utils/testHelpers'; +import * as hooks from '../../../../hooks/useFetcher'; +import { LicenseContext } from '../../../../context/LicenseContext'; +import { License } from '../../../../../../../../plugins/licensing/common/license'; const renderTransaction = async (transaction: Record<string, any>) => { const rendered = render( @@ -23,6 +30,15 @@ const renderTransaction = async (transaction: Record<string, any>) => { }; describe('TransactionActionMenu component', () => { + beforeAll(() => { + spyOn(hooks, 'useFetcher').and.returnValue({ + data: [], + status: 'success' + }); + }); + afterAll(() => { + jest.clearAllMocks(); + }); it('should always render the discover link', async () => { const { queryByText } = await renderTransaction( Transactions.transactionWithMinimalData @@ -124,4 +140,115 @@ describe('TransactionActionMenu component', () => { expect(container).toMatchSnapshot(); }); + + describe('Custom links', () => { + it('doesnt show custom links when license is not valid', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'gold', + status: 'invalid', + type: 'gold', + uid: '1' + } + }); + const component = render( + <LicenseContext.Provider value={license}> + <MockApmPluginContextWrapper> + <TransactionActionMenu + transaction={ + Transactions.transactionWithMinimalData as Transaction + } + /> + </MockApmPluginContextWrapper> + </LicenseContext.Provider> + ); + act(() => { + fireEvent.click(component.getByText('Actions')); + }); + expectTextsNotInDocument(component, ['Custom Links']); + }); + it('doesnt show custom links when basic license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'basic', + status: 'active', + type: 'basic', + uid: '1' + } + }); + const component = render( + <LicenseContext.Provider value={license}> + <MockApmPluginContextWrapper> + <TransactionActionMenu + transaction={ + Transactions.transactionWithMinimalData as Transaction + } + /> + </MockApmPluginContextWrapper> + </LicenseContext.Provider> + ); + act(() => { + fireEvent.click(component.getByText('Actions')); + }); + expectTextsNotInDocument(component, ['Custom Links']); + }); + it('shows custom links when trial license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'trial', + status: 'active', + type: 'trial', + uid: '1' + } + }); + const component = render( + <LicenseContext.Provider value={license}> + <MockApmPluginContextWrapper> + <TransactionActionMenu + transaction={ + Transactions.transactionWithMinimalData as Transaction + } + /> + </MockApmPluginContextWrapper> + </LicenseContext.Provider> + ); + act(() => { + fireEvent.click(component.getByText('Actions')); + }); + expectTextsInDocument(component, ['Custom Links']); + }); + it('shows custom links when gold license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'gold', + status: 'active', + type: 'gold', + uid: '1' + } + }); + const component = render( + <LicenseContext.Provider value={license}> + <MockApmPluginContextWrapper> + <TransactionActionMenu + transaction={ + Transactions.transactionWithMinimalData as Transaction + } + /> + </MockApmPluginContextWrapper> + </LicenseContext.Provider> + ); + act(() => { + fireEvent.click(component.getByText('Actions')); + }); + expectTextsInDocument(component, ['Custom Links']); + }); + }); }); diff --git a/x-pack/plugins/apm/common/custom_link_filter_options.ts b/x-pack/plugins/apm/common/custom_link_filter_options.ts new file mode 100644 index 0000000000000..32b19ad60a646 --- /dev/null +++ b/x-pack/plugins/apm/common/custom_link_filter_options.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 * as t from 'io-ts'; +import { + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_TYPE, + TRANSACTION_NAME +} from './elasticsearch_fieldnames'; + +export const FilterOptionsRt = t.partial({ + [SERVICE_NAME]: t.union([t.string, t.array(t.string)]), + [SERVICE_ENVIRONMENT]: t.union([t.string, t.array(t.string)]), + [TRANSACTION_NAME]: t.union([t.string, t.array(t.string)]), + [TRANSACTION_TYPE]: t.union([t.string, t.array(t.string)]) +}); + +export type FilterOptions = t.TypeOf<typeof FilterOptionsRt>; + +export const FILTER_OPTIONS: ReadonlyArray<keyof FilterOptions> = [ + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_TYPE, + TRANSACTION_NAME +] as const; diff --git a/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts b/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts index 0a0da332e73ae..cc01c990bf985 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts @@ -18,6 +18,7 @@ export type Mappings = scaling_factor?: number; ignore_malformed?: boolean; coerce?: boolean; + fields?: Record<string, Mappings>; }; export async function createOrUpdateIndex({ diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/get_transaction.test.ts.snap b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/get_transaction.test.ts.snap new file mode 100644 index 0000000000000..16a270fd6d25b --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/get_transaction.test.ts.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`custom link get transaction fetches with all filter 1`] = ` +Object { + "body": Object { + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + Object { + "term": Object { + "service.environment": "bar", + }, + }, + Object { + "term": Object { + "transaction.type": "qux", + }, + }, + Object { + "term": Object { + "transaction.name": "baz", + }, + }, + ], + }, + }, + }, + "index": "myIndex", + "size": 1, + "terminateAfter": 1, +} +`; + +exports[`custom link get transaction fetches without filter 1`] = ` +Object { + "body": Object { + "query": Object { + "bool": Object { + "filter": Array [], + }, + }, + }, + "index": "myIndex", + "size": 1, + "terminateAfter": 1, +} +`; + +exports[`custom link get transaction removes not listed filters from query 1`] = ` +Object { + "body": Object { + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + ], + }, + }, + }, + "index": "myIndex", + "size": 1, + "terminateAfter": 1, +} +`; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap index b3819ace40d6c..bb8f6dcb22902 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/__snapshots__/list_custom_links.test.ts.snap @@ -8,6 +8,13 @@ Object { "filter": Array [], }, }, + "sort": Array [ + Object { + "label.keyword": Object { + "order": "asc", + }, + }, + ], }, "index": "myIndex", "size": 500, @@ -69,6 +76,13 @@ Object { ], }, }, + "sort": Array [ + Object { + "label.keyword": Object { + "order": "asc", + }, + }, + ], }, "index": "myIndex", "size": 500, diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/get_transaction.test.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/get_transaction.test.ts new file mode 100644 index 0000000000000..4fc22298a476c --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/__test__/get_transaction.test.ts @@ -0,0 +1,56 @@ +/* + * 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 { + inspectSearchParams, + SearchParamsMock +} from '../../../../../../../legacy/plugins/apm/public/utils/testHelpers'; +import { getTransaction } from '../get_transaction'; +import { Setup } from '../../../helpers/setup_request'; +import { + SERVICE_NAME, + TRANSACTION_TYPE, + SERVICE_ENVIRONMENT, + TRANSACTION_NAME +} from '../../../../../common/elasticsearch_fieldnames'; + +describe('custom link get transaction', () => { + let mock: SearchParamsMock; + it('removes not listed filters from query', async () => { + mock = await inspectSearchParams(setup => + getTransaction({ + setup: (setup as unknown) as Setup, + // @ts-ignore ignoring the _debug is not part of filter options + filters: { _debug: true, [SERVICE_NAME]: 'foo' } + }) + ); + + expect(mock.params).toMatchSnapshot(); + }); + it('fetches without filter', async () => { + mock = await inspectSearchParams(setup => + getTransaction({ + setup: (setup as unknown) as Setup + }) + ); + + expect(mock.params).toMatchSnapshot(); + }); + it('fetches with all filter', async () => { + mock = await inspectSearchParams(setup => + getTransaction({ + setup: (setup as unknown) as Setup, + filters: { + [SERVICE_NAME]: 'foo', + [SERVICE_ENVIRONMENT]: 'bar', + [TRANSACTION_NAME]: 'baz', + [TRANSACTION_TYPE]: 'qux' + } + }) + ); + + expect(mock.params).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts index cdb3cff616030..1583e15bdecd5 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts @@ -31,7 +31,13 @@ const mappings: Mappings = { type: 'date' }, label: { - type: 'text' + type: 'text', + fields: { + // Adding keyword type to be able to sort by label alphabetically + keyword: { + type: 'keyword' + } + } }, url: { type: 'keyword' diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts index 809fe2050a072..5dce371e4f307 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts @@ -5,7 +5,7 @@ */ import { pick } from 'lodash'; -import { filterOptions } from '../../../routes/settings/custom_link'; +import { FILTER_OPTIONS } from '../../../../common/custom_link_filter_options'; import { APMIndexDocumentParams } from '../../helpers/es_client'; import { Setup } from '../../helpers/setup_request'; import { CustomLink } from './custom_link_types'; @@ -28,7 +28,7 @@ export async function createOrUpdateCustomLink({ '@timestamp': Date.now(), label: customLink.label, url: customLink.url, - ...pick(customLink, filterOptions) + ...pick(customLink, FILTER_OPTIONS) } }; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts index 60b97712713a9..edb9eb35b9029 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.d.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import * as t from 'io-ts'; -import { FilterOptions } from '../../../routes/settings/custom_link'; +import { FilterOptions } from '../../../../common/custom_link_filter_options'; export type CustomLink = { id?: string; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts new file mode 100644 index 0000000000000..396a7cb29f014 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts @@ -0,0 +1,38 @@ +/* + * 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 { pick } from 'lodash'; +import { + FilterOptions, + FILTER_OPTIONS +} from '../../../../common/custom_link_filter_options'; +import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; +import { Setup } from '../../helpers/setup_request'; + +export async function getTransaction({ + setup, + filters = {} +}: { + setup: Setup; + filters?: FilterOptions; +}) { + const { client, indices } = setup; + + const esFilters = Object.entries(pick(filters, FILTER_OPTIONS)).map( + ([key, value]) => { + return { term: { [key]: value } }; + } + ); + + const params = { + terminateAfter: 1, + index: indices['apm_oss.transactionIndices'], + size: 1, + body: { query: { bool: { filter: esFilters } } } + }; + const resp = await client.search<Transaction>(params); + return resp.hits.hits[0]?._source; +} diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts index e6052da73b0db..67956ef3a60ce 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { FilterOptions } from '../../../../common/custom_link_filter_options'; import { Setup } from '../../helpers/setup_request'; import { CustomLink } from './custom_link_types'; -import { FilterOptions } from '../../../routes/settings/custom_link'; export async function listCustomLinks({ setup, @@ -37,7 +37,14 @@ export async function listCustomLinks({ bool: { filter: esFilters } - } + }, + sort: [ + { + 'label.keyword': { + order: 'asc' + } + } + ] } }; const resp = await internalClient.search<CustomLink>(params); diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 34f0536a90b4d..50a794067bfad 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -63,7 +63,8 @@ import { createCustomLinkRoute, updateCustomLinkRoute, deleteCustomLinkRoute, - listCustomLinksRoute + listCustomLinksRoute, + customLinkTransactionRoute } from './settings/custom_link'; const createApmApi = () => { @@ -138,7 +139,8 @@ const createApmApi = () => { .add(createCustomLinkRoute) .add(updateCustomLinkRoute) .add(deleteCustomLinkRoute) - .add(listCustomLinksRoute); + .add(listCustomLinksRoute) + .add(customLinkTransactionRoute); return api; }; diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link.ts b/x-pack/plugins/apm/server/routes/settings/custom_link.ts index 5988d7f85b186..e11c1df9d4b16 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link.ts +++ b/x-pack/plugins/apm/server/routes/settings/custom_link.ts @@ -4,33 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ import * as t from 'io-ts'; -import { - SERVICE_NAME, - SERVICE_ENVIRONMENT, - TRANSACTION_NAME, - TRANSACTION_TYPE -} from '../../../common/elasticsearch_fieldnames'; +import { FilterOptionsRt } from '../../../common/custom_link_filter_options'; import { createRoute } from '../create_route'; import { setupRequest } from '../../lib/helpers/setup_request'; import { createOrUpdateCustomLink } from '../../lib/settings/custom_link/create_or_update_custom_link'; import { deleteCustomLink } from '../../lib/settings/custom_link/delete_custom_link'; import { listCustomLinks } from '../../lib/settings/custom_link/list_custom_links'; +import { getTransaction } from '../../lib/settings/custom_link/get_transaction'; -const FilterOptionsRt = t.partial({ - [SERVICE_NAME]: t.string, - [SERVICE_ENVIRONMENT]: t.string, - [TRANSACTION_NAME]: t.string, - [TRANSACTION_TYPE]: t.string -}); - -export type FilterOptions = t.TypeOf<typeof FilterOptionsRt>; - -export const filterOptions: Array<keyof FilterOptions> = [ - SERVICE_NAME, - SERVICE_ENVIRONMENT, - TRANSACTION_TYPE, - TRANSACTION_NAME -]; +export const customLinkTransactionRoute = createRoute(core => ({ + path: '/api/apm/settings/custom_links/transaction', + params: { + query: FilterOptionsRt + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { params } = context; + return await getTransaction({ setup, filters: params.query }); + } +})); export const listCustomLinksRoute = createRoute(core => ({ path: '/api/apm/settings/custom_links', diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/service.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/service.ts index 09020ce61c6e4..3ef852ebf6dd6 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/fields/service.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/service.ts @@ -6,6 +6,7 @@ export interface Service { name: string; + environment?: string; framework?: { name: string; version: string; From 7dc45f544154c5ed9ba7b532dd989635e6b8228c Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris <github@gidi.io> Date: Mon, 23 Mar 2020 13:12:53 +0000 Subject: [PATCH 005/179] removed boom errors from AlertNavigationRegistry (#60887) --- .../alert_navigation_registry/alert_navigation_registry.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.ts b/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.ts index 7f1919fbea684..f30629789b4ed 100644 --- a/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.ts +++ b/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; import { i18n } from '@kbn/i18n'; import { AlertType } from '../../common'; import { AlertNavigationHandler } from './types'; @@ -36,7 +35,7 @@ export class AlertNavigationRegistry { public registerDefault(consumer: string, handler: AlertNavigationHandler) { if (this.hasDefaultHandler(consumer)) { - throw Boom.badRequest( + throw new Error( i18n.translate('xpack.alerting.alertNavigationRegistry.register.duplicateDefaultError', { defaultMessage: 'Default Navigation within "{consumer}" is already registered.', values: { @@ -54,7 +53,7 @@ export class AlertNavigationRegistry { public register(consumer: string, alertType: AlertType, handler: AlertNavigationHandler) { if (this.hasTypedHandler(consumer, alertType)) { - throw Boom.badRequest( + throw new Error( i18n.translate('xpack.alerting.alertNavigationRegistry.register.duplicateNavigationError', { defaultMessage: 'Navigation for Alert type "{alertType}" within "{consumer}" is already registered.', @@ -78,7 +77,7 @@ export class AlertNavigationRegistry { return (consumerHandlers.get(alertType.id) ?? consumerHandlers.get(DEFAULT_HANDLER))!; } - throw Boom.badRequest( + throw new Error( i18n.translate('xpack.alerting.alertNavigationRegistry.get.missingNavigationError', { defaultMessage: 'Navigation for Alert type "{alertType}" within "{consumer}" is not registered.', From e235321903b0d8824d9850710392158d19b1dbd5 Mon Sep 17 00:00:00 2001 From: Bhavya RM <bhavya@elastic.co> Date: Mon, 23 Mar 2020 09:45:19 -0400 Subject: [PATCH 006/179] a11y tests for login and logout (#60799) a11y login screen --- x-pack/test/accessibility/apps/login_page.ts | 21 +++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/x-pack/test/accessibility/apps/login_page.ts b/x-pack/test/accessibility/apps/login_page.ts index 5b18b6be9e3a4..8c673bb332d91 100644 --- a/x-pack/test/accessibility/apps/login_page.ts +++ b/x-pack/test/accessibility/apps/login_page.ts @@ -28,14 +28,33 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.security.forceLogout(); }); - it('meets a11y requirements', async () => { + it('login page meets a11y requirements', async () => { await PageObjects.common.navigateToApp('login'); await retry.waitFor( 'login page visible', async () => await testSubjects.exists('loginSubmit') ); + await a11y.testAppSnapshot(); + }); + + it('User can login with a11y requirements', async () => { + await PageObjects.security.login(); + await a11y.testAppSnapshot(); + }); + + it('Wrong credentials message meets a11y requirements', async () => { + await PageObjects.security.loginPage.login('wrong-user', 'wrong-password', { + expectSuccess: false, + }); + await PageObjects.security.loginPage.getErrorMessage(); + await a11y.testAppSnapshot(); + }); + it('Logout message acknowledges a11y requirements', async () => { + await PageObjects.security.login(); + await PageObjects.security.logout(); + await testSubjects.getVisibleText('loginInfoMessage'); await a11y.testAppSnapshot(); }); }); From a5aafc039d8f445293ddf43d89ab58bdab61f83c Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris <github@gidi.io> Date: Mon, 23 Mar 2020 13:56:26 +0000 Subject: [PATCH 007/179] [Alerting] Fixes mistake in empty list assertion (#60896) --- .../page_objects/triggers_actions_ui_page.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts index 6c41c2cab801e..2a50c0117eae9 100644 --- a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts +++ b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts @@ -111,11 +111,11 @@ export function TriggersActionsPageProvider({ getService }: FtrProviderContext) const table = await find.byCssSelector('[data-test-subj="alertsList"] table'); const $ = await table.parseDomContent(); const rows = $.findTestSubjects('alert-row').toArray(); - expect(rows.length).not.to.eql(0); + expect(rows.length).to.eql(0); const emptyRow = await find.byCssSelector( '[data-test-subj="alertsList"] table .euiTableRow' ); - expect(await emptyRow.getVisibleText()).not.to.eql('No items found'); + expect(await emptyRow.getVisibleText()).to.eql('No items found'); }); return true; }, From c7b0ade01d7d01ebe4d9329e517a44be20ea833b Mon Sep 17 00:00:00 2001 From: Dmitry Lemeshko <dzmitry.lemechko@elastic.co> Date: Mon, 23 Mar 2020 17:06:19 +0300 Subject: [PATCH 008/179] skip flaky functional test (#60898) --- x-pack/test/functional/apps/uptime/settings.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/uptime/settings.ts b/x-pack/test/functional/apps/uptime/settings.ts index 0e804dd161c6b..aafb145a1b9b0 100644 --- a/x-pack/test/functional/apps/uptime/settings.ts +++ b/x-pack/test/functional/apps/uptime/settings.ts @@ -16,7 +16,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['uptime']); const es = getService('es'); - describe('uptime settings page', () => { + // Flaky https://github.com/elastic/kibana/issues/60866 + describe.skip('uptime settings page', () => { const settingsPage = () => pageObjects.uptime.settings; beforeEach('navigate to clean app root', async () => { // make 10 checks From c22dbb17641b5ea9a9ae0742e623f7b5c53ffbfc Mon Sep 17 00:00:00 2001 From: Brian Seeders <brian.seeders@elastic.co> Date: Mon, 23 Mar 2020 10:29:33 -0400 Subject: [PATCH 009/179] [CI] Add error steps and help links to PR comments (#60772) --- vars/githubPr.groovy | 19 +++++++++++++++++++ vars/jenkinsApi.groovy | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 vars/jenkinsApi.groovy diff --git a/vars/githubPr.groovy b/vars/githubPr.groovy index 0176424452d07..965fb1d4e108e 100644 --- a/vars/githubPr.groovy +++ b/vars/githubPr.groovy @@ -169,7 +169,20 @@ def getNextCommentMessage(previousCommentInfo = [:]) { ## :broken_heart: Build Failed * [continuous-integration/kibana-ci/pull-request](${env.BUILD_URL}) * Commit: ${getCommitHash()} + * [Pipeline Steps](${env.BUILD_URL}flowGraphTable) (look for red circles / failed steps) + * [Interpreting CI Failures](https://www.elastic.co/guide/en/kibana/current/interpreting-ci-failures.html) """ + + try { + def steps = getFailedSteps() + if (steps?.size() > 0) { + def list = steps.collect { "* [${it.displayName}](${it.logs})" }.join("\n") + messages << "### Failed CI Steps\n${list}" + } + } catch (ex) { + buildUtils.printStacktrace(ex) + print "Error retrieving failed pipeline steps for PR comment, will skip this section" + } } messages << getTestFailuresMessage() @@ -220,3 +233,9 @@ def deleteComment(commentId) { def getCommitHash() { return env.ghprbActualCommit } + +def getFailedSteps() { + return jenkinsApi.getFailedSteps()?.findAll { step -> + step.displayName != 'Check out from version control' + } +} diff --git a/vars/jenkinsApi.groovy b/vars/jenkinsApi.groovy new file mode 100644 index 0000000000000..1ea4c3dd76b8d --- /dev/null +++ b/vars/jenkinsApi.groovy @@ -0,0 +1,21 @@ +def getSteps() { + def url = "${env.BUILD_URL}api/json?tree=actions[nodes[iconColor,running,displayName,id,parents]]" + def responseRaw = httpRequest([ method: "GET", url: url ]) + def response = toJSON(responseRaw) + + def graphAction = response?.actions?.find { it._class == "org.jenkinsci.plugins.workflow.job.views.FlowGraphAction" } + + return graphAction?.nodes +} + +def getFailedSteps() { + def steps = getSteps() + def failedSteps = steps?.findAll { it.iconColor == "red" && it._class == "org.jenkinsci.plugins.workflow.cps.nodes.StepAtomNode" } + failedSteps.each { step -> + step.logs = "${env.BUILD_URL}execution/node/${step.id}/log".toString() + } + + return failedSteps +} + +return this From 42539a56ebd3dafc9dec92052d81508be8386377 Mon Sep 17 00:00:00 2001 From: Brian Seeders <brian.seeders@elastic.co> Date: Mon, 23 Mar 2020 10:30:14 -0400 Subject: [PATCH 010/179] Only run xpack siem cypress in PRs when there are siem changes (#60661) --- Jenkinsfile | 7 ++++- vars/prChanges.groovy | 11 ++++++-- vars/whenChanged.groovy | 57 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 vars/whenChanged.groovy diff --git a/Jenkinsfile b/Jenkinsfile index d43da6e0bee04..79d3c93006cb6 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -40,7 +40,12 @@ kibanaPipeline(timeoutMinutes: 135, checkPrChanges: true) { 'xpack-ciGroup9': kibanaPipeline.xpackCiGroupProcess(9), 'xpack-ciGroup10': kibanaPipeline.xpackCiGroupProcess(10), 'xpack-accessibility': kibanaPipeline.functionalTestProcess('xpack-accessibility', './test/scripts/jenkins_xpack_accessibility.sh'), - 'xpack-siemCypress': kibanaPipeline.functionalTestProcess('xpack-siemCypress', './test/scripts/jenkins_siem_cypress.sh'), + 'xpack-siemCypress': { processNumber -> + whenChanged(['x-pack/legacy/plugins/siem/', 'x-pack/test/siem_cypress/']) { + kibanaPipeline.functionalTestProcess('xpack-siemCypress', './test/scripts/jenkins_siem_cypress.sh')(processNumber) + } + }, + // 'xpack-visualRegression': kibanaPipeline.functionalTestProcess('xpack-visualRegression', './test/scripts/jenkins_xpack_visual_regression.sh'), ]), ]) diff --git a/vars/prChanges.groovy b/vars/prChanges.groovy index a9eb9027a0597..d7f46ee7be23e 100644 --- a/vars/prChanges.groovy +++ b/vars/prChanges.groovy @@ -1,3 +1,6 @@ +import groovy.transform.Field + +public static @Field PR_CHANGES_CACHE = null def getSkippablePaths() { return [ @@ -36,9 +39,13 @@ def areChangesSkippable() { } def getChanges() { - withGithubCredentials { - return githubPrs.getChanges(env.ghprbPullId) + if (!PR_CHANGES_CACHE && env.ghprbPullId) { + withGithubCredentials { + PR_CHANGES_CACHE = githubPrs.getChanges(env.ghprbPullId) + } } + + return PR_CHANGES_CACHE } def getChangedFiles() { diff --git a/vars/whenChanged.groovy b/vars/whenChanged.groovy new file mode 100644 index 0000000000000..c58ec83f2b051 --- /dev/null +++ b/vars/whenChanged.groovy @@ -0,0 +1,57 @@ +/* + whenChanged('some/path') { yourCode() } can be used to execute pipeline code in PRs only when changes are detected on paths that you specify. + The specified code blocks will also always be executed during the non-PR jobs for tracked branches. + + You have the option of passing in path prefixes, or regexes. Single or multiple. + Path specifications are NOT globby, they are only prefixes. + Specifying multiple will treat them as ORs. + + Example Usages: + whenChanged('a/path/prefix/') { someCode() } + whenChanged(startsWith: 'a/path/prefix/') { someCode() } // Same as above + whenChanged(['prefix1/', 'prefix2/']) { someCode() } + whenChanged(regex: /\.test\.js$/) { someCode() } + whenChanged(regex: [/abc/, /xyz/]) { someCode() } +*/ + +def call(String startsWithString, Closure closure) { + return whenChanged([ startsWith: startsWithString ], closure) +} + +def call(List<String> startsWithStrings, Closure closure) { + return whenChanged([ startsWith: startsWithStrings ], closure) +} + +def call(Map params, Closure closure) { + if (!githubPr.isPr()) { + return closure() + } + + def files = prChanges.getChangedFiles() + def hasMatch = false + + if (params.regex) { + params.regex = [] + params.regex + print "Checking PR for changes that match: ${params.regex.join(', ')}" + hasMatch = !!files.find { file -> + params.regex.find { regex -> file =~ regex } + } + } + + if (!hasMatch && params.startsWith) { + params.startsWith = [] + params.startsWith + print "Checking PR for changes that start with: ${params.startsWith.join(', ')}" + hasMatch = !!files.find { file -> + params.startsWith.find { str -> file.startsWith(str) } + } + } + + if (hasMatch) { + print "Changes found, executing pipeline." + closure() + } else { + print "No changes found, skipping." + } +} + +return this From 8572e3f18fb8f45aad96b76f5b3e1bf3873f04e4 Mon Sep 17 00:00:00 2001 From: Alison Goryachev <alison.goryachev@elastic.co> Date: Mon, 23 Mar 2020 10:42:40 -0400 Subject: [PATCH 011/179] [Remote clustersadopt changes to remote info API (#60795) --- .../common/lib/cluster_serialization.test.ts | 35 ++++++++++++++++++- .../common/lib/cluster_serialization.ts | 15 ++++---- .../server/routes/api/get_route.ts | 7 ---- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.test.ts b/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.test.ts index 5be6ed8828e6f..10b3dbbd9b452 100644 --- a/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.test.ts +++ b/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.test.ts @@ -13,11 +13,12 @@ describe('cluster_serialization', () => { expect(() => deserializeCluster('foo', 'bar')).toThrowError(); }); - it('should deserialize a complete cluster object', () => { + it('should deserialize a complete default cluster object', () => { expect( deserializeCluster('test_cluster', { seeds: ['localhost:9300'], connected: true, + mode: 'sniff', num_nodes_connected: 1, max_connections_per_cluster: 3, initial_connect_timeout: '30s', @@ -29,6 +30,7 @@ describe('cluster_serialization', () => { }) ).toEqual({ name: 'test_cluster', + mode: 'sniff', seeds: ['localhost:9300'], isConnected: true, connectedNodesCount: 1, @@ -40,6 +42,37 @@ describe('cluster_serialization', () => { }); }); + it('should deserialize a complete "proxy" mode cluster object', () => { + expect( + deserializeCluster('test_cluster', { + proxy_address: 'localhost:9300', + mode: 'proxy', + connected: true, + num_proxy_sockets_connected: 1, + max_proxy_socket_connections: 3, + initial_connect_timeout: '30s', + skip_unavailable: false, + server_name: 'my_server_name', + transport: { + ping_schedule: '-1', + compress: false, + }, + }) + ).toEqual({ + name: 'test_cluster', + mode: 'proxy', + proxyAddress: 'localhost:9300', + isConnected: true, + connectedSocketsCount: 1, + proxySocketConnections: 3, + initialConnectTimeout: '30s', + skipUnavailable: false, + transportPingSchedule: '-1', + transportCompress: false, + serverName: 'my_server_name', + }); + }); + it('should deserialize a cluster object without transport information', () => { expect( deserializeCluster('test_cluster', { diff --git a/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.ts b/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.ts index 53dc72eb1695a..fbea311cdeefa 100644 --- a/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.ts +++ b/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.ts @@ -18,9 +18,10 @@ export interface ClusterEs { ping_schedule?: string; compress?: boolean; }; - address?: string; - max_socket_connections?: number; - num_sockets_connected?: number; + proxy_address?: string; + max_proxy_socket_connections?: number; + num_proxy_sockets_connected?: number; + server_name?: string; } export interface Cluster { @@ -77,9 +78,10 @@ export function deserializeCluster( initial_connect_timeout: initialConnectTimeout, skip_unavailable: skipUnavailable, transport, - address: proxyAddress, - max_socket_connections: proxySocketConnections, - num_sockets_connected: connectedSocketsCount, + proxy_address: proxyAddress, + max_proxy_socket_connections: proxySocketConnections, + num_proxy_sockets_connected: connectedSocketsCount, + server_name: serverName, } = esClusterObject; let deserializedClusterObject: Cluster = { @@ -94,6 +96,7 @@ export function deserializeCluster( proxyAddress, proxySocketConnections, connectedSocketsCount, + serverName, }; if (transport) { diff --git a/x-pack/plugins/remote_clusters/server/routes/api/get_route.ts b/x-pack/plugins/remote_clusters/server/routes/api/get_route.ts index abd44977d8e46..8938f342674f0 100644 --- a/x-pack/plugins/remote_clusters/server/routes/api/get_route.ts +++ b/x-pack/plugins/remote_clusters/server/routes/api/get_route.ts @@ -45,16 +45,9 @@ export const register = (deps: RouteDependencies): void => { ? get(clusterSettings, `persistent.cluster.remote[${clusterName}].proxy`, undefined) : undefined; - // server_name is not available via the GET /_remote/info API, so we get it from the cluster settings - // Per https://github.com/elastic/kibana/pull/26067#issuecomment-441848124, we only look at persistent settings - const serverName = isPersistent - ? get(clusterSettings, `persistent.cluster.remote[${clusterName}].server_name`, undefined) - : undefined; - return { ...deserializeCluster(clusterName, cluster, deprecatedProxyAddress), isConfiguredByNode, - serverName, }; }); From a79087769471837440efb9c333b56fc04a809e23 Mon Sep 17 00:00:00 2001 From: Phillip Burch <phillip.burch@live.com> Date: Mon, 23 Mar 2020 10:02:11 -0500 Subject: [PATCH 012/179] [Metrics UI] Alerting for metrics explorer and inventory (#58779) * Add flyout with expressions * Integrate frontend with backend * Extended AlertContextValue with metadata optional property * Progress * Pre-fill criteria with current page filters * Better validation. Naming for clarity * Fix types for flyout * Respect the groupby property in metric explorer * Fix lint errors * Fix text, add toast notifications * Fix tests. Make sure update handles predefined expressions * Dynamically load source from alert flyout * Remove unused import * Simplify and add group by functionality * Remove unecessary useEffect * disable exhastive deps * Remove unecessary useEffect * change language * Implement design feedback * Add alert dropdown to the header and snapshot screen * Remove icon * Remove unused props. Code cleanup * Remove unused values * Fix formatted message id * Remove create alert option for now. * Fix type issue * Add rate, card and count as aggs * Fix types Co-authored-by: Yuliia Naumenko <yuliia.naumenko@elastic.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> Co-authored-by: Henry Harding <henry.harding@elastic.co> --- x-pack/plugins/infra/kibana.json | 3 +- .../plugins/infra/public/apps/start_app.tsx | 36 +- .../alerting/metrics/alert_dropdown.tsx | 62 +++ .../alerting/metrics/alert_flyout.tsx | 53 ++ .../alerting/metrics/expression.tsx | 473 ++++++++++++++++++ .../metrics/metric_threshold_alert_type.ts | 24 + .../alerting/metrics/validation.tsx | 80 +++ .../chart_context_menu.test.tsx | 2 +- .../metrics_explorer/chart_context_menu.tsx | 44 +- .../components/metrics_explorer/kuery_bar.tsx | 19 +- .../components/metrics_explorer/toolbar.tsx | 1 + .../components/waffle/node_context_menu.tsx | 83 +-- .../public/pages/infrastructure/index.tsx | 60 ++- x-pack/plugins/infra/public/plugin.ts | 11 +- .../public/utils/triggers_actions_context.tsx | 32 ++ 15 files changed, 887 insertions(+), 96 deletions(-) create mode 100644 x-pack/plugins/infra/public/components/alerting/metrics/alert_dropdown.tsx create mode 100644 x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx create mode 100644 x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx create mode 100644 x-pack/plugins/infra/public/components/alerting/metrics/metric_threshold_alert_type.ts create mode 100644 x-pack/plugins/infra/public/components/alerting/metrics/validation.tsx create mode 100644 x-pack/plugins/infra/public/utils/triggers_actions_context.tsx diff --git a/x-pack/plugins/infra/kibana.json b/x-pack/plugins/infra/kibana.json index bb40d65d311e8..b8796ad7a358e 100644 --- a/x-pack/plugins/infra/kibana.json +++ b/x-pack/plugins/infra/kibana.json @@ -11,7 +11,8 @@ "data", "dataEnhanced", "metrics", - "alerting" + "alerting", + "triggers_actions_ui" ], "server": true, "ui": true, diff --git a/x-pack/plugins/infra/public/apps/start_app.tsx b/x-pack/plugins/infra/public/apps/start_app.tsx index a797e4c9d4ba7..a986ee6ece352 100644 --- a/x-pack/plugins/infra/public/apps/start_app.tsx +++ b/x-pack/plugins/infra/public/apps/start_app.tsx @@ -15,7 +15,8 @@ import { CoreStart, AppMountParameters } from 'kibana/public'; // TODO use theme provided from parentApp when kibana supports it import { EuiErrorBoundary } from '@elastic/eui'; -import { EuiThemeProvider } from '../../../observability/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { EuiThemeProvider } from '../../../observability/public/typings/eui_styled_components'; import { InfraFrontendLibs } from '../lib/lib'; import { createStore } from '../store'; import { ApolloClientContext } from '../utils/apollo_context'; @@ -26,6 +27,8 @@ import { KibanaContextProvider, } from '../../../../../src/plugins/kibana_react/public'; import { AppRouter } from '../routers'; +import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public'; +import { TriggersActionsProvider } from '../utils/triggers_actions_context'; import '../index.scss'; export const CONTAINER_CLASSNAME = 'infra-container-element'; @@ -35,7 +38,8 @@ export async function startApp( core: CoreStart, plugins: object, params: AppMountParameters, - Router: AppRouter + Router: AppRouter, + triggersActionsUI: TriggersAndActionsUIPublicPluginSetup ) { const { element, appBasePath } = params; const history = createBrowserHistory({ basename: appBasePath }); @@ -51,19 +55,21 @@ export async function startApp( return ( <core.i18n.Context> <EuiErrorBoundary> - <ReduxStoreProvider store={store}> - <ReduxStateContextProvider> - <ApolloProvider client={libs.apolloClient}> - <ApolloClientContext.Provider value={libs.apolloClient}> - <EuiThemeProvider darkMode={darkMode}> - <HistoryContext.Provider value={history}> - <Router history={history} /> - </HistoryContext.Provider> - </EuiThemeProvider> - </ApolloClientContext.Provider> - </ApolloProvider> - </ReduxStateContextProvider> - </ReduxStoreProvider> + <TriggersActionsProvider triggersActionsUI={triggersActionsUI}> + <ReduxStoreProvider store={store}> + <ReduxStateContextProvider> + <ApolloProvider client={libs.apolloClient}> + <ApolloClientContext.Provider value={libs.apolloClient}> + <EuiThemeProvider darkMode={darkMode}> + <HistoryContext.Provider value={history}> + <Router history={history} /> + </HistoryContext.Provider> + </EuiThemeProvider> + </ApolloClientContext.Provider> + </ApolloProvider> + </ReduxStateContextProvider> + </ReduxStoreProvider> + </TriggersActionsProvider> </EuiErrorBoundary> </core.i18n.Context> ); diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/alert_dropdown.tsx b/x-pack/plugins/infra/public/components/alerting/metrics/alert_dropdown.tsx new file mode 100644 index 0000000000000..0a464d91fbe06 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/metrics/alert_dropdown.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import { EuiPopover, EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AlertFlyout } from './alert_flyout'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; + +export const AlertDropdown = () => { + const [popoverOpen, setPopoverOpen] = useState(false); + const [flyoutVisible, setFlyoutVisible] = useState(false); + const kibana = useKibana(); + + const closePopover = useCallback(() => { + setPopoverOpen(false); + }, [setPopoverOpen]); + + const openPopover = useCallback(() => { + setPopoverOpen(true); + }, [setPopoverOpen]); + + const menuItems = useMemo(() => { + return [ + <EuiContextMenuItem icon="bell" key="createLink" onClick={() => setFlyoutVisible(true)}> + <FormattedMessage + id="xpack.infra.alerting.createAlertButton" + defaultMessage="Create alert" + /> + </EuiContextMenuItem>, + <EuiContextMenuItem + icon="tableOfContents" + key="manageLink" + href={kibana.services?.application?.getUrlForApp( + 'kibana#/management/kibana/triggersActions/alerts' + )} + > + <FormattedMessage id="xpack.infra.alerting.manageAlerts" defaultMessage="Manage Alerts" /> + </EuiContextMenuItem>, + ]; + }, [kibana.services]); + + return ( + <> + <EuiPopover + button={ + <EuiButtonEmpty iconSide={'right'} iconType={'arrowDown'} onClick={openPopover}> + <FormattedMessage id="xpack.infra.alerting.alertsButton" defaultMessage="Alerts" /> + </EuiButtonEmpty> + } + isOpen={popoverOpen} + closePopover={closePopover} + > + <EuiContextMenuPanel items={menuItems} /> + </EuiPopover> + <AlertFlyout setVisible={setFlyoutVisible} visible={flyoutVisible} /> + </> + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx b/x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx new file mode 100644 index 0000000000000..a00d63af8aac2 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { AlertsContextProvider, AlertAdd } from '../../../../../triggers_actions_ui/public'; +import { TriggerActionsContext } from '../../../utils/triggers_actions_context'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/metric_threshold/types'; +import { MetricsExplorerOptions } from '../../../containers/metrics_explorer/use_metrics_explorer_options'; +import { MetricsExplorerSeries } from '../../../../common/http_api/metrics_explorer'; + +interface Props { + visible?: boolean; + options?: Partial<MetricsExplorerOptions>; + series?: MetricsExplorerSeries; + setVisible: React.Dispatch<React.SetStateAction<boolean>>; +} + +export const AlertFlyout = (props: Props) => { + const { triggersActionsUI } = useContext(TriggerActionsContext); + const { services } = useKibana(); + + return ( + <> + {triggersActionsUI && ( + <AlertsContextProvider + value={{ + metadata: { + currentOptions: props.options, + series: props.series, + }, + toastNotifications: services.notifications?.toasts, + http: services.http, + actionTypeRegistry: triggersActionsUI.actionTypeRegistry, + alertTypeRegistry: triggersActionsUI.alertTypeRegistry, + }} + > + <AlertAdd + addFlyoutVisible={props.visible!} + setAddFlyoutVisibility={props.setVisible} + alertTypeId={METRIC_THRESHOLD_ALERT_TYPE_ID} + canChangeTrigger={false} + consumer={'metrics'} + /> + </AlertsContextProvider> + )} + </> + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx b/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx new file mode 100644 index 0000000000000..ea8dd1484a670 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx @@ -0,0 +1,473 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useMemo, useEffect, useState } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonIcon, + EuiSpacer, + EuiText, + EuiFormRow, + EuiButtonEmpty, +} from '@elastic/eui'; +import { IFieldType } from 'src/plugins/data/public'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { euiStyled } from '../../../../../observability/public'; +import { + WhenExpression, + OfExpression, + ThresholdExpression, + ForLastExpression, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../triggers_actions_ui/public/common'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IErrorObject } from '../../../../../triggers_actions_ui/public/types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertsContextValue } from '../../../../../triggers_actions_ui/public/application/context/alerts_context'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { MetricsExplorerOptions } from '../../../containers/metrics_explorer/use_metrics_explorer_options'; +import { MetricsExplorerKueryBar } from '../../metrics_explorer/kuery_bar'; +import { MetricsExplorerSeries } from '../../../../common/http_api/metrics_explorer'; +import { useSource } from '../../../containers/source'; +import { MetricsExplorerGroupBy } from '../../metrics_explorer/group_by'; + +export interface MetricExpression { + aggType?: string; + metric?: string; + comparator?: Comparator; + threshold?: number[]; + timeSize?: number; + timeUnit?: TimeUnit; + indexPattern?: string; +} + +interface AlertContextMeta { + currentOptions?: Partial<MetricsExplorerOptions>; + series?: MetricsExplorerSeries; +} + +interface Props { + errors: IErrorObject[]; + alertParams: { + criteria: MetricExpression[]; + groupBy?: string; + filterQuery?: string; + }; + alertsContext: AlertsContextValue<AlertContextMeta>; + setAlertParams(key: string, value: any): void; + setAlertProperty(key: string, value: any): void; +} + +type Comparator = '>' | '>=' | 'between' | '<' | '<='; +type TimeUnit = 's' | 'm' | 'h' | 'd'; + +export const Expressions: React.FC<Props> = props => { + const { setAlertParams, alertParams, errors, alertsContext } = props; + const { source, createDerivedIndexPattern } = useSource({ sourceId: 'default' }); + const [timeSize, setTimeSize] = useState<number | undefined>(1); + const [timeUnit, setTimeUnit] = useState<TimeUnit>('s'); + + const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ + createDerivedIndexPattern, + ]); + + const options = useMemo<MetricsExplorerOptions>(() => { + if (alertsContext.metadata?.currentOptions?.metrics) { + return alertsContext.metadata.currentOptions as MetricsExplorerOptions; + } else { + return { + metrics: [], + aggregation: 'avg', + }; + } + }, [alertsContext.metadata]); + + const defaultExpression = useMemo<MetricExpression>( + () => ({ + aggType: AGGREGATION_TYPES.MAX, + comparator: '>', + threshold: [], + timeSize: 1, + timeUnit: 's', + indexPattern: source?.configuration.metricAlias, + }), + [source] + ); + + const updateParams = useCallback( + (id, e: MetricExpression) => { + const exp = alertParams.criteria ? alertParams.criteria.slice() : []; + exp[id] = { ...exp[id], ...e }; + setAlertParams('criteria', exp); + }, + [setAlertParams, alertParams.criteria] + ); + + const addExpression = useCallback(() => { + const exp = alertParams.criteria.slice(); + exp.push(defaultExpression); + setAlertParams('criteria', exp); + }, [setAlertParams, alertParams.criteria, defaultExpression]); + + const removeExpression = useCallback( + (id: number) => { + const exp = alertParams.criteria.slice(); + if (exp.length > 1) { + exp.splice(id, 1); + setAlertParams('criteria', exp); + } + }, + [setAlertParams, alertParams.criteria] + ); + + const onFilterQuerySubmit = useCallback( + (filter: any) => { + setAlertParams('filterQuery', filter); + }, + [setAlertParams] + ); + + const onGroupByChange = useCallback( + (group: string | null) => { + setAlertParams('groupBy', group || undefined); + }, + [setAlertParams] + ); + + const emptyError = useMemo(() => { + return { + aggField: [], + timeSizeUnit: [], + timeWindowSize: [], + }; + }, []); + + const updateTimeSize = useCallback( + (ts: number | undefined) => { + const criteria = alertParams.criteria.map(c => ({ + ...c, + timeSize: ts, + })); + setTimeSize(ts || undefined); + setAlertParams('criteria', criteria); + }, + [alertParams.criteria, setAlertParams] + ); + + const updateTimeUnit = useCallback( + (tu: string) => { + const criteria = alertParams.criteria.map(c => ({ + ...c, + timeUnit: tu, + })); + setTimeUnit(tu as TimeUnit); + setAlertParams('criteria', criteria); + }, + [alertParams.criteria, setAlertParams] + ); + + useEffect(() => { + const md = alertsContext.metadata; + if (md) { + if (md.currentOptions?.metrics) { + setAlertParams( + 'criteria', + md.currentOptions.metrics.map(metric => ({ + metric: metric.field, + comparator: '>', + threshold: [], + timeSize, + timeUnit, + indexPattern: source?.configuration.metricAlias, + aggType: metric.aggregation, + })) + ); + } else { + setAlertParams('criteria', [defaultExpression]); + } + + if (md.currentOptions) { + if (md.currentOptions.filterQuery) { + setAlertParams('filterQuery', md.currentOptions.filterQuery); + } else if (md.currentOptions.groupBy && md.series) { + const filter = `${md.currentOptions.groupBy}: "${md.series.id}"`; + setAlertParams('filterQuery', filter); + } + + setAlertParams('groupBy', md.currentOptions.groupBy); + } + } + }, [alertsContext.metadata, defaultExpression, source]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + <> + <EuiSpacer size={'m'} /> + <EuiText size="xs"> + <h4> + <FormattedMessage + id="xpack.infra.metrics.alertFlyout.conditions" + defaultMessage="Conditions" + /> + </h4> + </EuiText> + <EuiSpacer size={'xs'} /> + {alertParams.criteria && + alertParams.criteria.map((e, idx) => { + return ( + <ExpressionRow + canDelete={alertParams.criteria.length > 1} + fields={derivedIndexPattern.fields} + remove={removeExpression} + addExpression={addExpression} + key={idx} // idx's don't usually make good key's but here the index has semantic meaning + expressionId={idx} + setAlertParams={updateParams} + errors={errors[idx] || emptyError} + expression={e || {}} + /> + ); + })} + + <ForLastExpression + timeWindowSize={timeSize} + timeWindowUnit={timeUnit} + errors={emptyError} + onChangeWindowSize={updateTimeSize} + onChangeWindowUnit={updateTimeUnit} + /> + + <div> + <EuiButtonEmpty + color={'primary'} + iconSide={'left'} + flush={'left'} + iconType={'plusInCircleFilled'} + onClick={addExpression} + > + <FormattedMessage + id="xpack.infra.metrics.alertFlyout.addCondition" + defaultMessage="Add condition" + /> + </EuiButtonEmpty> + </div> + + <EuiSpacer size={'m'} /> + + <EuiFormRow + label={i18n.translate('xpack.infra.metrics.alertFlyout.filterLabel', { + defaultMessage: 'Filter', + })} + helpText={i18n.translate('xpack.infra.metrics.alertFlyout.filterHelpText', { + defaultMessage: 'Filter help text', + })} + fullWidth + compressed + > + <MetricsExplorerKueryBar + derivedIndexPattern={derivedIndexPattern} + onSubmit={onFilterQuerySubmit} + value={alertParams.filterQuery} + /> + </EuiFormRow> + + <EuiSpacer size={'m'} /> + + {alertsContext.metadata && ( + <EuiFormRow + label={i18n.translate('xpack.infra.metrics.alertFlyout.createAlertPerText', { + defaultMessage: 'Create alert per', + })} + helpText={i18n.translate('xpack.infra.metrics.alertFlyout.createAlertPerHelpText', { + defaultMessage: 'Create alert help text', + })} + fullWidth + compressed + > + <MetricsExplorerGroupBy + onChange={onGroupByChange} + fields={derivedIndexPattern.fields} + options={{ + ...options, + groupBy: alertParams.groupBy || undefined, + }} + /> + </EuiFormRow> + )} + </> + ); +}; + +interface ExpressionRowProps { + fields: IFieldType[]; + expressionId: number; + expression: MetricExpression; + errors: IErrorObject; + canDelete: boolean; + addExpression(): void; + remove(id: number): void; + setAlertParams(id: number, params: MetricExpression): void; +} + +const StyledExpressionRow = euiStyled(EuiFlexGroup)` + display: flex; + flex-wrap: wrap; + margin: 0 -${props => props.theme.eui.euiSizeXS}; +`; + +const StyledExpression = euiStyled.div` + padding: 0 ${props => props.theme.eui.euiSizeXS}; +`; + +export const ExpressionRow: React.FC<ExpressionRowProps> = props => { + const { setAlertParams, expression, errors, expressionId, remove, fields, canDelete } = props; + const { aggType = AGGREGATION_TYPES.MAX, metric, comparator = '>', threshold = [] } = expression; + + const updateAggType = useCallback( + (at: string) => { + setAlertParams(expressionId, { ...expression, aggType: at }); + }, + [expressionId, expression, setAlertParams] + ); + + const updateMetric = useCallback( + (m?: string) => { + setAlertParams(expressionId, { ...expression, metric: m }); + }, + [expressionId, expression, setAlertParams] + ); + + const updateComparator = useCallback( + (c?: string) => { + setAlertParams(expressionId, { ...expression, comparator: c as Comparator }); + }, + [expressionId, expression, setAlertParams] + ); + + const updateThreshold = useCallback( + t => { + setAlertParams(expressionId, { ...expression, threshold: t }); + }, + [expressionId, expression, setAlertParams] + ); + + return ( + <> + <EuiFlexGroup gutterSize="xs"> + <EuiFlexItem grow> + <StyledExpressionRow> + <StyledExpression> + <WhenExpression + customAggTypesOptions={aggregationType} + aggType={aggType} + onChangeSelectedAggType={updateAggType} + /> + </StyledExpression> + {aggType !== 'count' && ( + <StyledExpression> + <OfExpression + customAggTypesOptions={aggregationType} + aggField={metric} + fields={fields.map(f => ({ + normalizedType: f.type, + name: f.name, + }))} + aggType={aggType} + errors={errors} + onChangeSelectedAggField={updateMetric} + /> + </StyledExpression> + )} + <StyledExpression> + <ThresholdExpression + thresholdComparator={comparator || '>'} + threshold={threshold} + onChangeSelectedThresholdComparator={updateComparator} + onChangeSelectedThreshold={updateThreshold} + errors={errors} + /> + </StyledExpression> + </StyledExpressionRow> + </EuiFlexItem> + {canDelete && ( + <EuiFlexItem grow={false}> + <EuiButtonIcon + aria-label={i18n.translate('xpack.infra.metrics.alertFlyout.removeCondition', { + defaultMessage: 'Remove condition', + })} + color={'danger'} + iconType={'trash'} + onClick={() => remove(expressionId)} + /> + </EuiFlexItem> + )} + </EuiFlexGroup> + <EuiSpacer size={'s'} /> + </> + ); +}; + +enum AGGREGATION_TYPES { + COUNT = 'count', + AVERAGE = 'avg', + SUM = 'sum', + MIN = 'min', + MAX = 'max', + RATE = 'rate', + CARDINALITY = 'cardinality', +} + +export const aggregationType: { [key: string]: any } = { + avg: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.avg', { + defaultMessage: 'Average', + }), + fieldRequired: true, + validNormalizedTypes: ['number'], + value: AGGREGATION_TYPES.AVERAGE, + }, + max: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.max', { + defaultMessage: 'Max', + }), + fieldRequired: true, + validNormalizedTypes: ['number', 'date'], + value: AGGREGATION_TYPES.MAX, + }, + min: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.min', { + defaultMessage: 'Min', + }), + fieldRequired: true, + validNormalizedTypes: ['number', 'date'], + value: AGGREGATION_TYPES.MIN, + }, + cardinality: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.cardinality', { + defaultMessage: 'Cardinality', + }), + fieldRequired: false, + value: AGGREGATION_TYPES.CARDINALITY, + validNormalizedTypes: ['number'], + }, + rate: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.rate', { + defaultMessage: 'Rate', + }), + fieldRequired: false, + value: AGGREGATION_TYPES.RATE, + validNormalizedTypes: ['number'], + }, + count: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.count', { + defaultMessage: 'Document count', + }), + fieldRequired: false, + value: AGGREGATION_TYPES.COUNT, + validNormalizedTypes: ['number'], + }, +}; diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/metric_threshold_alert_type.ts b/x-pack/plugins/infra/public/components/alerting/metrics/metric_threshold_alert_type.ts new file mode 100644 index 0000000000000..d3b5aaa7c8796 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/metrics/metric_threshold_alert_type.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertTypeModel } from '../../../../../triggers_actions_ui/public/types'; +import { Expressions } from './expression'; +import { validateMetricThreshold } from './validation'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/metric_threshold/types'; + +export function getAlertType(): AlertTypeModel { + return { + id: METRIC_THRESHOLD_ALERT_TYPE_ID, + name: i18n.translate('xpack.infra.metrics.alertFlyout.alertName', { + defaultMessage: 'Alert Trigger', + }), + iconClass: 'bell', + alertParamsExpression: Expressions, + validate: validateMetricThreshold, + }; +} diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/validation.tsx b/x-pack/plugins/infra/public/components/alerting/metrics/validation.tsx new file mode 100644 index 0000000000000..0f5b07f8c0e13 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/metrics/validation.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths + +import { MetricExpression } from './expression'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ValidationResult } from '../../../../../triggers_actions_ui/public/types'; + +export function validateMetricThreshold({ + criteria, +}: { + criteria: MetricExpression[]; +}): ValidationResult { + const validationResult = { errors: {} }; + const errors: { + [id: string]: { + aggField: string[]; + timeSizeUnit: string[]; + timeWindowSize: string[]; + threshold0: string[]; + threshold1: string[]; + }; + } = {}; + validationResult.errors = errors; + + if (!criteria || !criteria.length) { + return validationResult; + } + + criteria.forEach((c, idx) => { + // Create an id for each criteria, so we can map errors to specific criteria. + const id = idx.toString(); + + errors[id] = errors[id] || { + aggField: [], + timeSizeUnit: [], + timeWindowSize: [], + threshold0: [], + threshold1: [], + }; + if (!c.aggType) { + errors[id].aggField.push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.aggregationRequired', { + defaultMessage: 'Aggreation is required.', + }) + ); + } + + if (!c.threshold || !c.threshold.length) { + errors[id].threshold0.push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdRequired', { + defaultMessage: 'Threshold is required.', + }) + ); + } + + if (c.comparator === 'between' && (!c.threshold || c.threshold.length < 2)) { + errors[id].threshold1.push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdRequired', { + defaultMessage: 'Threshold is required.', + }) + ); + } + + if (!c.timeSize) { + errors[id].timeWindowSize.push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.timeRequred', { + defaultMessage: 'Time size is Required.', + }) + ); + } + }); + + return validationResult; +} diff --git a/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx b/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx index a23a2739a8e23..8ffef269a42ea 100644 --- a/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx +++ b/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx @@ -143,7 +143,7 @@ describe('MetricsExplorerChartContextMenu', () => { uiCapabilities: customUICapabilities, chartOptions, }); - expect(component.find('button').length).toBe(0); + expect(component.find('button').length).toBe(1); }); }); diff --git a/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx b/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx index c50550f1de56f..75a04cbe9799e 100644 --- a/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx +++ b/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx @@ -24,6 +24,7 @@ import { createTSVBLink } from './helpers/create_tsvb_link'; import { getNodeDetailUrl } from '../../pages/link_to/redirect_to_node_detail'; import { SourceConfiguration } from '../../utils/source_configuration'; import { InventoryItemType } from '../../../common/inventory_models/types'; +import { AlertFlyout } from '../alerting/metrics/alert_flyout'; import { useLinkProps } from '../../hooks/use_link_props'; export interface Props { @@ -81,6 +82,7 @@ export const MetricsExplorerChartContextMenu: React.FC<Props> = ({ chartOptions, }: Props) => { const [isPopoverOpen, setPopoverState] = useState(false); + const [flyoutVisible, setFlyoutVisible] = useState(false); const supportFiltering = options.groupBy != null && onFilter != null; const handleFilter = useCallback(() => { // onFilter needs check for Typescript even though it's @@ -141,7 +143,20 @@ export const MetricsExplorerChartContextMenu: React.FC<Props> = ({ ] : []; - const itemPanels = [...filterByItem, ...openInVisualize, ...viewNodeDetail]; + const itemPanels = [ + ...filterByItem, + ...openInVisualize, + ...viewNodeDetail, + { + name: i18n.translate('xpack.infra.metricsExplorer.alerts.createAlertButton', { + defaultMessage: 'Create alert', + }), + icon: 'bell', + onClick() { + setFlyoutVisible(true); + }, + }, + ]; // If there are no itemPanels then there is no reason to show the actions button. if (itemPanels.length === 0) return null; @@ -174,15 +189,24 @@ export const MetricsExplorerChartContextMenu: React.FC<Props> = ({ {actionLabel} </EuiButtonEmpty> ); + return ( - <EuiPopover - closePopover={handleClose} - id={`${series.id}-popover`} - button={button} - isOpen={isPopoverOpen} - panelPaddingSize="none" - > - <EuiContextMenu initialPanelId={0} panels={panels} /> - </EuiPopover> + <> + <EuiPopover + closePopover={handleClose} + id={`${series.id}-popover`} + button={button} + isOpen={isPopoverOpen} + panelPaddingSize="none" + > + <EuiContextMenu initialPanelId={0} panels={panels} /> + <AlertFlyout + series={series} + options={options} + setVisible={setFlyoutVisible} + visible={flyoutVisible} + /> + </EuiPopover> + </> ); }; diff --git a/x-pack/plugins/infra/public/components/metrics_explorer/kuery_bar.tsx b/x-pack/plugins/infra/public/components/metrics_explorer/kuery_bar.tsx index 0e18deedd404c..dcc160d05b6ad 100644 --- a/x-pack/plugins/infra/public/components/metrics_explorer/kuery_bar.tsx +++ b/x-pack/plugins/infra/public/components/metrics_explorer/kuery_bar.tsx @@ -16,6 +16,7 @@ interface Props { derivedIndexPattern: IIndexPattern; onSubmit: (query: string) => void; value?: string | null; + placeholder?: string; } function validateQuery(query: string) { @@ -27,7 +28,12 @@ function validateQuery(query: string) { return true; } -export const MetricsExplorerKueryBar = ({ derivedIndexPattern, onSubmit, value }: Props) => { +export const MetricsExplorerKueryBar = ({ + derivedIndexPattern, + onSubmit, + value, + placeholder, +}: Props) => { const [draftQuery, setDraftQuery] = useState<string>(value || ''); const [isValid, setValidation] = useState<boolean>(true); @@ -48,9 +54,12 @@ export const MetricsExplorerKueryBar = ({ derivedIndexPattern, onSubmit, value } fields: derivedIndexPattern.fields.filter(field => isDisplayable(field)), }; - const placeholder = i18n.translate('xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder', { - defaultMessage: 'Search for infrastructure data… (e.g. host.name:host-1)', - }); + const defaultPlaceholder = i18n.translate( + 'xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder', + { + defaultMessage: 'Search for infrastructure data… (e.g. host.name:host-1)', + } + ); return ( <WithKueryAutocompletion indexPattern={filteredDerivedIndexPattern}> @@ -62,7 +71,7 @@ export const MetricsExplorerKueryBar = ({ derivedIndexPattern, onSubmit, value } loadSuggestions={loadSuggestions} onChange={handleChange} onSubmit={onSubmit} - placeholder={placeholder} + placeholder={placeholder || defaultPlaceholder} suggestions={suggestions} value={draftQuery} /> diff --git a/x-pack/plugins/infra/public/components/metrics_explorer/toolbar.tsx b/x-pack/plugins/infra/public/components/metrics_explorer/toolbar.tsx index 9e96819a36cac..0fbb0b6acad17 100644 --- a/x-pack/plugins/infra/public/components/metrics_explorer/toolbar.tsx +++ b/x-pack/plugins/infra/public/components/metrics_explorer/toolbar.tsx @@ -63,6 +63,7 @@ export const MetricsExplorerToolbar = ({ const isDefaultOptions = options.aggregation === 'avg' && options.metrics.length === 0; const [timepickerQuickRanges] = useKibanaUiSetting('timepicker:quickRanges'); const commonlyUsedRanges = mapKibanaQuickRangesToDatePickerRanges(timepickerQuickRanges); + return ( <Toolbar> <EuiFlexGroup alignItems="center"> diff --git a/x-pack/plugins/infra/public/components/waffle/node_context_menu.tsx b/x-pack/plugins/infra/public/components/waffle/node_context_menu.tsx index cc6a94c8a41a2..5f05cebd8f616 100644 --- a/x-pack/plugins/infra/public/components/waffle/node_context_menu.tsx +++ b/x-pack/plugins/infra/public/components/waffle/node_context_menu.tsx @@ -8,7 +8,7 @@ import { EuiPopoverProps, EuiCode } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../lib/lib'; import { getNodeDetailUrl, getNodeLogsUrl } from '../../pages/link_to'; import { createUptimeLink } from './lib/create_uptime_link'; @@ -25,6 +25,7 @@ import { SectionLink, } from '../../../../observability/public'; import { useLinkProps } from '../../hooks/use_link_props'; +import { AlertFlyout } from '../alerting/metrics/alert_flyout'; interface Props { options: InfraWaffleMapOptions; @@ -46,6 +47,7 @@ export const NodeContextMenu: React.FC<Props> = ({ nodeType, popoverPosition, }) => { + const [flyoutVisible, setFlyoutVisible] = useState(false); const inventoryModel = findInventoryModel(nodeType); const nodeDetailFrom = currentTime - inventoryModel.metrics.defaultTimeRangeInSeconds * 1000; const uiCapabilities = useKibana().services.application?.capabilities; @@ -144,41 +146,48 @@ export const NodeContextMenu: React.FC<Props> = ({ }; return ( - <ActionMenu - closePopover={closePopover} - id={`${node.pathId}-popover`} - isOpen={isPopoverOpen} - button={children!} - anchorPosition={popoverPosition} - > - <div style={{ maxWidth: 300 }} data-test-subj="nodeContextMenu"> - <Section> - <SectionTitle> - <FormattedMessage - id="xpack.infra.nodeContextMenu.title" - defaultMessage="{inventoryName} details" - values={{ inventoryName: inventoryModel.singularDisplayName }} - /> - </SectionTitle> - {inventoryId.label && ( - <SectionSubtitle> - <div style={{ wordBreak: 'break-all' }}> - <FormattedMessage - id="xpack.infra.nodeContextMenu.description" - defaultMessage="View details for {label} {value}" - values={{ label: inventoryId.label, value: inventoryId.value }} - /> - </div> - </SectionSubtitle> - )} - <SectionLinks> - <SectionLink data-test-subj="viewLogsContextMenuItem" {...nodeLogsMenuItem} /> - <SectionLink {...nodeDetailMenuItem} /> - <SectionLink data-test-subj="viewApmTracesContextMenuItem" {...apmTracesMenuItem} /> - <SectionLink {...uptimeMenuItem} /> - </SectionLinks> - </Section> - </div> - </ActionMenu> + <> + <ActionMenu + closePopover={closePopover} + id={`${node.pathId}-popover`} + isOpen={isPopoverOpen} + button={children!} + anchorPosition={popoverPosition} + > + <div style={{ maxWidth: 300 }} data-test-subj="nodeContextMenu"> + <Section> + <SectionTitle> + <FormattedMessage + id="xpack.infra.nodeContextMenu.title" + defaultMessage="{inventoryName} details" + values={{ inventoryName: inventoryModel.singularDisplayName }} + /> + </SectionTitle> + {inventoryId.label && ( + <SectionSubtitle> + <div style={{ wordBreak: 'break-all' }}> + <FormattedMessage + id="xpack.infra.nodeContextMenu.description" + defaultMessage="View details for {label} {value}" + values={{ label: inventoryId.label, value: inventoryId.value }} + /> + </div> + </SectionSubtitle> + )} + <SectionLinks> + <SectionLink data-test-subj="viewLogsContextMenuItem" {...nodeLogsMenuItem} /> + <SectionLink {...nodeDetailMenuItem} /> + <SectionLink data-test-subj="viewApmTracesContextMenuItem" {...apmTracesMenuItem} /> + <SectionLink {...uptimeMenuItem} /> + </SectionLinks> + </Section> + </div> + </ActionMenu> + <AlertFlyout + options={{ filterQuery: `${nodeType}: ${node.id}` }} + setVisible={setFlyoutVisible} + visible={flyoutVisible} + /> + </> ); }; diff --git a/x-pack/plugins/infra/public/pages/infrastructure/index.tsx b/x-pack/plugins/infra/public/pages/infrastructure/index.tsx index b4ff7aeff696c..730f67ab2bdca 100644 --- a/x-pack/plugins/infra/public/pages/infrastructure/index.tsx +++ b/x-pack/plugins/infra/public/pages/infrastructure/index.tsx @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { Route, RouteComponentProps, Switch } from 'react-router-dom'; +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { DocumentTitle } from '../../components/document_title'; import { HelpCenterContent } from '../../components/help_center_content'; import { RoutedTabs } from '../../components/navigation/routed_tabs'; @@ -24,9 +25,11 @@ import { MetricsSettingsPage } from './settings'; import { AppNavigation } from '../../components/navigation/app_navigation'; import { SourceLoadingPage } from '../../components/source_loading_page'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { AlertDropdown } from '../../components/alerting/metrics/alert_dropdown'; export const InfrastructurePage = ({ match }: RouteComponentProps) => { const uiCapabilities = useKibana().services.application?.capabilities; + return ( <Source.Provider sourceId="default"> <ColumnarPage> @@ -59,31 +62,38 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { defaultMessage: 'Metrics', })} > - <RoutedTabs - tabs={[ - { - app: 'metrics', - title: i18n.translate('xpack.infra.homePage.inventoryTabTitle', { - defaultMessage: 'Inventory', - }), - pathname: '/inventory', - }, - { - app: 'metrics', - title: i18n.translate('xpack.infra.homePage.metricsExplorerTabTitle', { - defaultMessage: 'Metrics Explorer', - }), - pathname: '/explorer', - }, - { - app: 'metrics', - title: i18n.translate('xpack.infra.homePage.settingsTabTitle', { - defaultMessage: 'Settings', - }), - pathname: '/settings', - }, - ]} - /> + <EuiFlexGroup gutterSize={'none'} alignItems={'center'}> + <EuiFlexItem> + <RoutedTabs + tabs={[ + { + app: 'metrics', + title: i18n.translate('xpack.infra.homePage.inventoryTabTitle', { + defaultMessage: 'Inventory', + }), + pathname: '/inventory', + }, + { + app: 'metrics', + title: i18n.translate('xpack.infra.homePage.metricsExplorerTabTitle', { + defaultMessage: 'Metrics Explorer', + }), + pathname: '/explorer', + }, + { + app: 'metrics', + title: i18n.translate('xpack.infra.homePage.settingsTabTitle', { + defaultMessage: 'Settings', + }), + pathname: '/settings', + }, + ]} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <AlertDropdown /> + </EuiFlexItem> + </EuiFlexGroup> </AppNavigation> <Switch> diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index d576331662a08..15796f35856bd 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -29,6 +29,8 @@ import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/pl import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; import { DataEnhancedSetup, DataEnhancedStart } from '../../data_enhanced/public'; import { LogsRouter, MetricsRouter } from './routers'; +import { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public'; +import { getAlertType } from './components/alerting/metrics/metric_threshold_alert_type'; export type ClientSetup = void; export type ClientStart = void; @@ -38,6 +40,7 @@ export interface ClientPluginsSetup { data: DataPublicPluginSetup; usageCollection: UsageCollectionSetup; dataEnhanced: DataEnhancedSetup; + triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; } export interface ClientPluginsStart { @@ -58,6 +61,8 @@ export class Plugin setup(core: CoreSetup, pluginsSetup: ClientPluginsSetup) { registerFeatures(pluginsSetup.home); + pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getAlertType()); + core.application.register({ id: 'logs', title: i18n.translate('xpack.infra.logs.pluginTitle', { @@ -76,7 +81,8 @@ export class Plugin coreStart, plugins, params, - LogsRouter + LogsRouter, + pluginsSetup.triggers_actions_ui ); }, }); @@ -99,7 +105,8 @@ export class Plugin coreStart, plugins, params, - MetricsRouter + MetricsRouter, + pluginsSetup.triggers_actions_ui ); }, }); diff --git a/x-pack/plugins/infra/public/utils/triggers_actions_context.tsx b/x-pack/plugins/infra/public/utils/triggers_actions_context.tsx new file mode 100644 index 0000000000000..4ca4aedb4a08b --- /dev/null +++ b/x-pack/plugins/infra/public/utils/triggers_actions_context.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public'; + +interface ContextProps { + triggersActionsUI: TriggersAndActionsUIPublicPluginSetup | null; +} + +export const TriggerActionsContext = React.createContext<ContextProps>({ + triggersActionsUI: null, +}); + +interface Props { + triggersActionsUI: TriggersAndActionsUIPublicPluginSetup; +} + +export const TriggersActionsProvider: React.FC<Props> = props => { + return ( + <TriggerActionsContext.Provider + value={{ + triggersActionsUI: props.triggersActionsUI, + }} + > + {props.children} + </TriggerActionsContext.Provider> + ); +}; From 3401ae42e0b9d700a91b6933f3310b61ee19789e Mon Sep 17 00:00:00 2001 From: Luke Elmers <luke.elmers@elastic.co> Date: Mon, 23 Mar 2020 09:17:27 -0600 Subject: [PATCH 013/179] =?UTF-8?q?Goodbye,=20legacy=20data=20plugin=20?= =?UTF-8?q?=F0=9F=91=8B=20(#60449)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintrc.js | 6 -- .i18nrc.json | 5 +- src/legacy/core_plugins/data/index.ts | 41 ------------- src/legacy/core_plugins/data/package.json | 4 -- src/legacy/core_plugins/data/public/index.ts | 26 --------- src/legacy/core_plugins/data/public/legacy.ts | 44 -------------- src/legacy/core_plugins/data/public/plugin.ts | 58 ------------------- src/legacy/core_plugins/data/public/setup.ts | 23 -------- .../core_plugins/input_control_vis/index.ts | 2 +- .../core_plugins/kibana/public/.eslintrc.js | 2 - src/legacy/core_plugins/timelion/index.ts | 2 +- .../core_plugins/timelion/public/app.js | 1 - .../public/markdown_vis_controller.test.tsx | 5 -- .../core_plugins/vis_type_timelion/index.ts | 2 +- .../components/panel_config/gauge.test.js | 6 -- .../components/vis_types/gauge/series.test.js | 6 -- .../vis_types/metric/series.test.js | 6 -- .../core_plugins/vis_type_vislib/index.ts | 2 +- src/legacy/core_plugins/vis_type_xy/index.ts | 2 +- x-pack/legacy/plugins/lens/index.ts | 2 +- .../lens/public/app_plugin/app.test.tsx | 5 -- .../dimension_panel/dimension_panel.test.tsx | 6 -- .../indexpattern_suggestions.test.tsx | 6 +- 23 files changed, 8 insertions(+), 254 deletions(-) delete mode 100644 src/legacy/core_plugins/data/index.ts delete mode 100644 src/legacy/core_plugins/data/package.json delete mode 100644 src/legacy/core_plugins/data/public/index.ts delete mode 100644 src/legacy/core_plugins/data/public/legacy.ts delete mode 100644 src/legacy/core_plugins/data/public/plugin.ts delete mode 100644 src/legacy/core_plugins/data/public/setup.ts diff --git a/.eslintrc.js b/.eslintrc.js index 3d6a5c262c453..af05af0f6e402 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -69,12 +69,6 @@ module.exports = { 'jsx-a11y/no-onchange': 'off', }, }, - { - files: ['src/legacy/core_plugins/data/**/*.{js,ts,tsx}'], - rules: { - 'react-hooks/exhaustive-deps': 'off', - }, - }, { files: ['src/legacy/core_plugins/expressions/**/*.{js,ts,tsx}'], rules: { diff --git a/.i18nrc.json b/.i18nrc.json index 07878ed3c15fb..bffe99bf3654b 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -4,10 +4,7 @@ "console": "src/plugins/console", "core": "src/core", "dashboard": "src/plugins/dashboard", - "data": [ - "src/legacy/core_plugins/data", - "src/plugins/data" - ], + "data": "src/plugins/data", "embeddableApi": "src/plugins/embeddable", "embeddableExamples": "examples/embeddable_examples", "share": "src/plugins/share", diff --git a/src/legacy/core_plugins/data/index.ts b/src/legacy/core_plugins/data/index.ts deleted file mode 100644 index 10c8cf464b82d..0000000000000 --- a/src/legacy/core_plugins/data/index.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 { resolve } from 'path'; -import { Legacy } from '../../../../kibana'; - -// eslint-disable-next-line import/no-default-export -export default function DataPlugin(kibana: any) { - const config: Legacy.PluginSpecOptions = { - id: 'data', - require: ['elasticsearch'], - publicDir: resolve(__dirname, 'public'), - config: (Joi: any) => { - return Joi.object({ - enabled: Joi.boolean().default(true), - }).default(); - }, - init: (server: Legacy.Server) => ({}), - uiExports: { - injectDefaultVars: () => ({}), - }, - }; - - return new kibana.Plugin(config); -} diff --git a/src/legacy/core_plugins/data/package.json b/src/legacy/core_plugins/data/package.json deleted file mode 100644 index 3f40374650ad7..0000000000000 --- a/src/legacy/core_plugins/data/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "data", - "version": "kibana" -} diff --git a/src/legacy/core_plugins/data/public/index.ts b/src/legacy/core_plugins/data/public/index.ts deleted file mode 100644 index 27a3dd825485d..0000000000000 --- a/src/legacy/core_plugins/data/public/index.ts +++ /dev/null @@ -1,26 +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 { DataPlugin as Plugin } from './plugin'; - -export function plugin() { - return new Plugin(); -} - -export { DataSetup, DataStart } from './plugin'; diff --git a/src/legacy/core_plugins/data/public/legacy.ts b/src/legacy/core_plugins/data/public/legacy.ts deleted file mode 100644 index 370b412127db8..0000000000000 --- a/src/legacy/core_plugins/data/public/legacy.ts +++ /dev/null @@ -1,44 +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. - */ - -/** - * New Platform Shim - * - * In this file, we import any legacy dependencies we have, and shim them into - * our plugin by manually constructing the values that the new platform will - * eventually be passing to the `setup` method of our plugin definition. - * - * The idea is that our `plugin.ts` can stay "pure" and not contain any legacy - * world code. Then when it comes time to migrate to the new platform, we can - * simply delete this shim file. - * - * We are also calling `setup` here and exporting our public contract so that - * other legacy plugins are able to import from '../core_plugins/data/legacy' - * and receive the response value of the `setup` contract, mimicking the - * data that will eventually be injected by the new platform. - */ - -import { npSetup, npStart } from 'ui/new_platform'; -import { plugin } from '.'; - -const dataPlugin = plugin(); - -export const setup = dataPlugin.setup(npSetup.core); - -export const start = dataPlugin.start(npStart.core); diff --git a/src/legacy/core_plugins/data/public/plugin.ts b/src/legacy/core_plugins/data/public/plugin.ts deleted file mode 100644 index 76a3d92d20283..0000000000000 --- a/src/legacy/core_plugins/data/public/plugin.ts +++ /dev/null @@ -1,58 +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 { CoreSetup, CoreStart, Plugin } from 'kibana/public'; - -/** - * Interface for this plugin's returned `setup` contract. - * - * @public - */ -export interface DataSetup {} // eslint-disable-line @typescript-eslint/no-empty-interface - -/** - * Interface for this plugin's returned `start` contract. - * - * @public - */ -export interface DataStart {} // eslint-disable-line @typescript-eslint/no-empty-interface - -/** - * Data Plugin - public - * - * This is the entry point for the entire client-side public contract of the plugin. - * If something is not explicitly exported here, you can safely assume it is private - * to the plugin and not considered stable. - * - * All stateful contracts will be injected by the platform at runtime, and are defined - * in the setup/start interfaces. The remaining items exported here are either types, - * or static code. - */ - -export class DataPlugin implements Plugin<DataSetup, DataStart> { - public setup(core: CoreSetup) { - return {}; - } - - public start(core: CoreStart): DataStart { - return {}; - } - - public stop() {} -} diff --git a/src/legacy/core_plugins/data/public/setup.ts b/src/legacy/core_plugins/data/public/setup.ts deleted file mode 100644 index a99a2a4d06efe..0000000000000 --- a/src/legacy/core_plugins/data/public/setup.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 { setup } from './legacy'; - -// for backwards compatibility with 7.3 -export const data = setup; diff --git a/src/legacy/core_plugins/input_control_vis/index.ts b/src/legacy/core_plugins/input_control_vis/index.ts index 8f6178e26126b..d67472ac4b95f 100644 --- a/src/legacy/core_plugins/input_control_vis/index.ts +++ b/src/legacy/core_plugins/input_control_vis/index.ts @@ -25,7 +25,7 @@ import { LegacyPluginApi, LegacyPluginInitializer } from '../../../../src/legacy const inputControlVisPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => new Plugin({ id: 'input_control_vis', - require: ['kibana', 'elasticsearch', 'visualizations', 'interpreter', 'data'], + require: ['kibana', 'elasticsearch', 'visualizations', 'interpreter'], publicDir: resolve(__dirname, 'public'), uiExports: { styleSheetPaths: resolve(__dirname, 'public/index.scss'), diff --git a/src/legacy/core_plugins/kibana/public/.eslintrc.js b/src/legacy/core_plugins/kibana/public/.eslintrc.js index e7171a5291d26..1153706eb8566 100644 --- a/src/legacy/core_plugins/kibana/public/.eslintrc.js +++ b/src/legacy/core_plugins/kibana/public/.eslintrc.js @@ -43,8 +43,6 @@ function buildRestrictedPaths(shimmedPlugins) { 'ui/**/*', 'src/legacy/ui/**/*', 'src/legacy/core_plugins/kibana/public/**/*', - 'src/legacy/core_plugins/data/public/**/*', - '!src/legacy/core_plugins/data/public/index.ts', `!src/legacy/core_plugins/kibana/public/${shimmedPlugin}/**/*`, ], allowSameFolder: false, diff --git a/src/legacy/core_plugins/timelion/index.ts b/src/legacy/core_plugins/timelion/index.ts index 9e2bfd4023bd9..41a15dc4e0186 100644 --- a/src/legacy/core_plugins/timelion/index.ts +++ b/src/legacy/core_plugins/timelion/index.ts @@ -29,7 +29,7 @@ const experimentalLabel = i18n.translate('timelion.uiSettings.experimentalLabel' const timelionPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => new Plugin({ - require: ['kibana', 'elasticsearch', 'data'], + require: ['kibana', 'elasticsearch'], config(Joi: any) { return Joi.object({ enabled: Joi.boolean().default(true), diff --git a/src/legacy/core_plugins/timelion/public/app.js b/src/legacy/core_plugins/timelion/public/app.js index a9d678cfea79c..66d93b4ce9b89 100644 --- a/src/legacy/core_plugins/timelion/public/app.js +++ b/src/legacy/core_plugins/timelion/public/app.js @@ -38,7 +38,6 @@ import 'ui/directives/input_focus'; import './directives/saved_object_finder'; import 'ui/directives/listen'; import './directives/saved_object_save_as_checkbox'; -import '../../data/public/legacy'; import './services/saved_sheet_register'; import rootTemplate from 'plugins/timelion/index.html'; diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.test.tsx b/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.test.tsx index 5bcb2961c42de..103879cb6e6df 100644 --- a/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.test.tsx +++ b/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.test.tsx @@ -21,11 +21,6 @@ import React from 'react'; import { render, mount } from 'enzyme'; import { MarkdownVisWrapper } from './markdown_vis_controller'; -// We need Markdown to do these tests, so mock data plugin -jest.mock('../../data/public/legacy', () => { - return {}; -}); - describe('markdown vis controller', () => { it('should set html from markdown params', () => { const vis = { diff --git a/src/legacy/core_plugins/vis_type_timelion/index.ts b/src/legacy/core_plugins/vis_type_timelion/index.ts index 4664bebb4f38a..6c1e3f452959e 100644 --- a/src/legacy/core_plugins/vis_type_timelion/index.ts +++ b/src/legacy/core_plugins/vis_type_timelion/index.ts @@ -25,7 +25,7 @@ import { LegacyPluginApi, LegacyPluginInitializer } from '../../../../src/legacy const timelionVisPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => new Plugin({ id: 'timelion_vis', - require: ['kibana', 'elasticsearch', 'visualizations', 'data'], + require: ['kibana', 'elasticsearch', 'visualizations'], publicDir: resolve(__dirname, 'public'), uiExports: { styleSheetPaths: resolve(__dirname, 'public/index.scss'), diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/gauge.test.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/gauge.test.js index d92dafadb68bc..4509b669b0505 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/gauge.test.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/panel_config/gauge.test.js @@ -20,12 +20,6 @@ import React from 'react'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; -jest.mock('plugins/data', () => { - return { - QueryStringInput: () => <div className="queryStringInput" />, - }; -}); - jest.mock('../lib/get_default_query_language', () => ({ getDefaultQueryLanguage: () => 'kuery', })); diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/gauge/series.test.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/gauge/series.test.js index 4efd5bb65451c..65bf7561e3866 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/gauge/series.test.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/gauge/series.test.js @@ -20,12 +20,6 @@ import React from 'react'; import { GaugeSeries } from './series'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -jest.mock('plugins/data', () => { - return { - QueryStringInput: () => <div className="queryStringInput" />, - }; -}); - const defaultProps = { disableAdd: true, disableDelete: true, diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/metric/series.test.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/metric/series.test.js index 299e7c12f931a..94a12266df3b3 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/metric/series.test.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/metric/series.test.js @@ -21,12 +21,6 @@ import React from 'react'; import { MetricSeries } from './series'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -jest.mock('plugins/data', () => { - return { - QueryStringInput: () => <div className="queryStringInput" />, - }; -}); - const defaultProps = { disableAdd: false, disableDelete: true, diff --git a/src/legacy/core_plugins/vis_type_vislib/index.ts b/src/legacy/core_plugins/vis_type_vislib/index.ts index 74c8f3f96e669..1f75aea31ba0b 100644 --- a/src/legacy/core_plugins/vis_type_vislib/index.ts +++ b/src/legacy/core_plugins/vis_type_vislib/index.ts @@ -25,7 +25,7 @@ import { LegacyPluginApi, LegacyPluginInitializer } from '../../types'; const visTypeVislibPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => new Plugin({ id: 'vis_type_vislib', - require: ['kibana', 'elasticsearch', 'visualizations', 'interpreter', 'data'], + require: ['kibana', 'elasticsearch', 'visualizations', 'interpreter'], publicDir: resolve(__dirname, 'public'), styleSheetPaths: resolve(__dirname, 'public/index.scss'), uiExports: { diff --git a/src/legacy/core_plugins/vis_type_xy/index.ts b/src/legacy/core_plugins/vis_type_xy/index.ts index 975399f891503..58d2e425eef40 100644 --- a/src/legacy/core_plugins/vis_type_xy/index.ts +++ b/src/legacy/core_plugins/vis_type_xy/index.ts @@ -31,7 +31,7 @@ export interface ConfigSchema { const visTypeXyPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => new Plugin({ id: 'visTypeXy', - require: ['kibana', 'elasticsearch', 'visualizations', 'interpreter', 'data'], + require: ['kibana', 'elasticsearch', 'visualizations', 'interpreter'], publicDir: resolve(__dirname, 'public'), uiExports: { hacks: [resolve(__dirname, 'public/legacy')], diff --git a/x-pack/legacy/plugins/lens/index.ts b/x-pack/legacy/plugins/lens/index.ts index 5eda6c4b4ff7a..b1c67fb81ba07 100644 --- a/x-pack/legacy/plugins/lens/index.ts +++ b/x-pack/legacy/plugins/lens/index.ts @@ -19,7 +19,7 @@ export const lens: LegacyPluginInitializer = kibana => { id: PLUGIN_ID, configPrefix: `xpack.${PLUGIN_ID}`, // task_manager could be required, but is only used for telemetry - require: ['kibana', 'elasticsearch', 'xpack_main', 'interpreter', 'data'], + require: ['kibana', 'elasticsearch', 'xpack_main', 'interpreter'], publicDir: resolve(__dirname, 'public'), uiExports: { diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx index d6312005a6c25..fbda18cc0e307 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx @@ -22,7 +22,6 @@ import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks' const dataStartMock = dataPluginMock.createStartContract(); import { TopNavMenuData } from '../../../../../../src/plugins/navigation/public'; -import { DataStart } from '../../../../../../src/legacy/core_plugins/data/public'; import { coreMock } from 'src/core/public/mocks'; jest.mock('ui/new_platform'); @@ -87,7 +86,6 @@ describe('Lens App', () => { editorFrame: EditorFrameInstance; data: typeof dataStartMock; core: typeof core; - dataShim: DataStart; storage: Storage; docId?: string; docStorage: SavedObjectStore; @@ -134,7 +132,6 @@ describe('Lens App', () => { editorFrame: EditorFrameInstance; data: typeof dataStartMock; core: typeof core; - dataShim: DataStart; storage: Storage; docId?: string; docStorage: SavedObjectStore; @@ -332,7 +329,6 @@ describe('Lens App', () => { editorFrame: EditorFrameInstance; data: typeof dataStartMock; core: typeof core; - dataShim: DataStart; storage: Storage; docId?: string; docStorage: SavedObjectStore; @@ -648,7 +644,6 @@ describe('Lens App', () => { editorFrame: EditorFrameInstance; data: typeof dataStartMock; core: typeof core; - dataShim: DataStart; storage: Storage; docId?: string; docStorage: SavedObjectStore; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 41c317ccab290..f4485774bc942 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -34,12 +34,6 @@ jest.mock('ui/new_platform'); jest.mock('../loader'); jest.mock('../state_helpers'); -// Used by indexpattern plugin, which is a dependency of a dependency -jest.mock('ui/chrome'); -// Contains old and new platform data plugins, used for interpreter and filter ratio -jest.mock('ui/new_platform'); -jest.mock('plugins/data/setup', () => ({ data: { query: { ui: {} } } })); - const expectedIndexPatterns = { 1: { id: '1', diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index e86a16c1af9d6..4e48d0c0987b5 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -12,13 +12,9 @@ import { getDatasourceSuggestionsFromCurrentState, } from './indexpattern_suggestions'; +jest.mock('ui/new_platform'); jest.mock('./loader'); jest.mock('../id_generator'); -// chrome, notify, storage are used by ./plugin -jest.mock('ui/chrome'); -// Contains old and new platform data plugins, used for interpreter and filter ratio -jest.mock('ui/new_platform'); -jest.mock('plugins/data/setup', () => ({ data: { query: { ui: {} } } })); const expectedIndexPatterns = { 1: { From 85615bdb3f30da61882501a7a20a8e2dcb1af55b Mon Sep 17 00:00:00 2001 From: Wylie Conlon <william.conlon@elastic.co> Date: Mon, 23 Mar 2020 11:32:07 -0400 Subject: [PATCH 014/179] Fix formatter on range aggregation (#58651) * Fix formatter on range aggregation * Fix test that was using unformatted byte ranges * Fix test Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../data/public/field_formats/utils/deserialize.ts | 3 ++- test/functional/apps/visualize/_data_table.js | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/plugins/data/public/field_formats/utils/deserialize.ts b/src/plugins/data/public/field_formats/utils/deserialize.ts index c735ad196fbee..840e023a11589 100644 --- a/src/plugins/data/public/field_formats/utils/deserialize.ts +++ b/src/plugins/data/public/field_formats/utils/deserialize.ts @@ -70,7 +70,8 @@ export const deserializeFieldFormat: FormatFactory = function( const { id } = mapping; if (id === 'range') { const RangeFormat = FieldFormat.from((range: any) => { - const format = getFieldFormat(this, id, mapping.params); + const nestedFormatter = mapping.params as SerializedFieldFormat; + const format = getFieldFormat(this, nestedFormatter.id, nestedFormatter.params); const gte = '\u2265'; const lt = '\u003c'; return i18n.translate('data.aggTypes.buckets.ranges.rangesFormatMessage', { diff --git a/test/functional/apps/visualize/_data_table.js b/test/functional/apps/visualize/_data_table.js index 0a9ff1e77a2ef..a6305e158007d 100644 --- a/test/functional/apps/visualize/_data_table.js +++ b/test/functional/apps/visualize/_data_table.js @@ -99,9 +99,9 @@ export default function({ getService, getPageObjects }) { async function expectValidTableData() { const data = await PageObjects.visChart.getTableVisData(); expect(data.trim().split('\n')).to.be.eql([ - '≥ 0 and < 1000', + '≥ 0B and < 1,000B', '1,351 64.7%', - '≥ 1000 and < 2000', + '≥ 1,000B and < 1.953KB', '737 35.3%', ]); } @@ -144,9 +144,9 @@ export default function({ getService, getPageObjects }) { const data = await PageObjects.visChart.getTableVisData(); expect(data.trim().split('\n')).to.be.eql([ - '≥ 0 and < 1000', + '≥ 0B and < 1,000B', '344.094B', - '≥ 1000 and < 2000', + '≥ 1,000B and < 1.953KB', '1.697KB', ]); }); @@ -248,9 +248,9 @@ export default function({ getService, getPageObjects }) { await PageObjects.visEditor.clickGo(); const data = await PageObjects.visChart.getTableVisData(); expect(data.trim().split('\n')).to.be.eql([ - '≥ 0 and < 1000', + '≥ 0B and < 1,000B', '1,351', - '≥ 1000 and < 2000', + '≥ 1,000B and < 1.953KB', '737', ]); }); From 1b583a2e27174c5e81367da352aa8ae61534965a Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Mon, 23 Mar 2020 18:42:04 +0300 Subject: [PATCH 015/179] [TSVB] Fix percentiles band mode (#60741) * Fix percentiles band mode * Add support of bar chart, fix tests * Use accessor formatters * Fix tests --- .../public/components/aggs/percentile_ui.js | 2 + .../public/visualizations/constants/chart.js | 1 + .../timeseries/decorators/area_decorator.js | 7 ++- .../timeseries/decorators/bar_decorator.js | 7 ++- .../visualizations/views/timeseries/index.js | 6 +++ .../response_processors/series/percentile.js | 51 ++++++++++--------- .../series/percentile.test.js | 44 +++++----------- 7 files changed, 61 insertions(+), 57 deletions(-) diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_ui.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_ui.js index b931c8084a61e..f94c2f609da8f 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_ui.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/aggs/percentile_ui.js @@ -135,6 +135,8 @@ class PercentilesUi extends Component { <EuiFlexItem style={optionsStyle} grow={false}> <EuiFieldNumber id={htmlId('fillTo')} + min={0} + max={100} step={1} onChange={this.handleTextChange(model, 'percentile')} value={Number(model.percentile)} diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/constants/chart.js b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/constants/chart.js index 2e7eae438de12..d5ecbaa2ade06 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/constants/chart.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/constants/chart.js @@ -32,6 +32,7 @@ export const GRID_LINE_CONFIG = { export const X_ACCESSOR_INDEX = 0; export const STACK_ACCESSORS = [0]; export const Y_ACCESSOR_INDEXES = [1]; +export const Y0_ACCESSOR_INDEXES = [2]; export const STACKED_OPTIONS = { NONE: 'none', diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/area_decorator.js b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/area_decorator.js index 923024ff690a4..0afe773266a61 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/area_decorator.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/area_decorator.js @@ -21,7 +21,7 @@ import React from 'react'; import { ScaleType, AreaSeries } from '@elastic/charts'; import { getAreaStyles } from '../utils/series_styles'; import { ChartsEntities } from '../model/charts'; -import { X_ACCESSOR_INDEX, Y_ACCESSOR_INDEXES } from '../../../constants'; +import { X_ACCESSOR_INDEX, Y_ACCESSOR_INDEXES, Y0_ACCESSOR_INDEXES } from '../../../constants'; export function AreaSeriesDecorator({ seriesId, @@ -40,6 +40,8 @@ export function AreaSeriesDecorator({ enableHistogramMode, useDefaultGroupDomain, sortIndex, + y1AccessorFormat, + y0AccessorFormat, }) { const id = seriesId; const groupId = seriesGroupId; @@ -54,6 +56,9 @@ export function AreaSeriesDecorator({ hideInLegend, xAccessor: X_ACCESSOR_INDEX, yAccessors: Y_ACCESSOR_INDEXES, + y0Accessors: lines.mode === 'band' ? Y0_ACCESSOR_INDEXES : undefined, + y1AccessorFormat, + y0AccessorFormat, stackAccessors, stackAsPercentage, xScaleType, diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/bar_decorator.js b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/bar_decorator.js index 6d2cd7b8dd935..c979920caac6d 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/bar_decorator.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/decorators/bar_decorator.js @@ -21,7 +21,7 @@ import React from 'react'; import { ScaleType, BarSeries } from '@elastic/charts'; import { getBarStyles } from '../utils/series_styles'; import { ChartsEntities } from '../model/charts'; -import { X_ACCESSOR_INDEX, Y_ACCESSOR_INDEXES } from '../../../constants'; +import { X_ACCESSOR_INDEX, Y_ACCESSOR_INDEXES, Y0_ACCESSOR_INDEXES } from '../../../constants'; export function BarSeriesDecorator({ seriesId, @@ -39,6 +39,8 @@ export function BarSeriesDecorator({ enableHistogramMode, useDefaultGroupDomain, sortIndex, + y1AccessorFormat, + y0AccessorFormat, }) { const id = seriesId; const groupId = seriesGroupId; @@ -53,6 +55,9 @@ export function BarSeriesDecorator({ hideInLegend, xAccessor: X_ACCESSOR_INDEX, yAccessors: Y_ACCESSOR_INDEXES, + y0Accessors: bars.mode === 'band' ? Y0_ACCESSOR_INDEXES : undefined, + y1AccessorFormat, + y0AccessorFormat, stackAccessors, stackAsPercentage, xScaleType, diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js index 5673f560214c7..bec76433eb8ac 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js @@ -156,6 +156,8 @@ export const TimeSeries = ({ stack, points, useDefaultGroupDomain, + y1AccessorFormat, + y0AccessorFormat, }, sortIndex ) => { @@ -182,6 +184,8 @@ export const TimeSeries = ({ enableHistogramMode={enableHistogramMode} useDefaultGroupDomain={useDefaultGroupDomain} sortIndex={sortIndex} + y1AccessorFormat={y1AccessorFormat} + y0AccessorFormat={y0AccessorFormat} /> ); } @@ -206,6 +210,8 @@ export const TimeSeries = ({ enableHistogramMode={enableHistogramMode} useDefaultGroupDomain={useDefaultGroupDomain} sortIndex={sortIndex} + y1AccessorFormat={y1AccessorFormat} + y0AccessorFormat={y0AccessorFormat} /> ); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.js index 669a96a43ff8d..00fb48c88ec3f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.js @@ -17,7 +17,6 @@ * under the License. */ -import _ from 'lodash'; import { getAggValue } from '../../helpers/get_agg_value'; import { getDefaultDecoration } from '../../helpers/get_default_decoration'; import { getSplits } from '../../helpers/get_splits'; @@ -35,41 +34,45 @@ export function percentile(resp, panel, series, meta) { getSplits(resp, panel, series, meta).forEach(split => { metric.percentiles.forEach(percentile => { const percentileValue = percentile.value ? percentile.value : 0; - const label = `${split.label} (${percentileValue})`; + const id = `${split.id}:${percentile.id}`; const data = split.timeseries.buckets.map(bucket => { - const m = _.assign({}, metric, { percent: percentileValue }); - return [bucket.key, getAggValue(bucket, m)]; + const higherMetric = { ...metric, percent: percentileValue }; + const serieData = [bucket.key, getAggValue(bucket, higherMetric)]; + + if (percentile.mode === 'band') { + const lowerMetric = { ...metric, percent: percentile.percentile }; + serieData.push(getAggValue(bucket, lowerMetric)); + } + + return serieData; }); if (percentile.mode === 'band') { - const fillData = split.timeseries.buckets.map(bucket => { - const m = _.assign({}, metric, { percent: percentile.percentile }); - return [bucket.key, getAggValue(bucket, m)]; - }); results.push({ - id: `${split.id}:${percentile.id}`, + id, color: split.color, - label, + label: split.label, data, - lines: { show: true, fill: percentile.shade, lineWidth: 0 }, - points: { show: false }, - legend: false, - fillBetween: `${split.id}:${percentile.id}:${percentile.percentile}`, - }); - results.push({ - id: `${split.id}:${percentile.id}:${percentile.percentile}`, - color: split.color, - label, - data: fillData, - lines: { show: true, fill: false, lineWidth: 0 }, - legend: false, + lines: { + show: series.chart_type === 'line', + fill: Number(percentile.shade), + lineWidth: 0, + mode: 'band', + }, + bars: { + show: series.chart_type === 'bar', + fill: Number(percentile.shade), + mode: 'band', + }, points: { show: false }, + y1AccessorFormat: ` (${percentileValue})`, + y0AccessorFormat: ` (${percentile.percentile})`, }); } else { const decoration = getDefaultDecoration(series); results.push({ - id: `${split.id}:${percentile.id}`, + id, color: split.color, - label, + label: `${split.label} (${percentileValue})`, data, ...decoration, }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.test.js index 9cb08de8dad23..aec1c45cf97e1 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.test.js @@ -89,63 +89,45 @@ describe('percentile(resp, panel, series)', () => { test('creates a series', () => { const next = results => results; const results = percentile(resp, panel, series)(next)([]); - expect(results).toHaveLength(3); + expect(results).toHaveLength(2); expect(results[0]).toHaveProperty('id', 'test:10-90'); expect(results[0]).toHaveProperty('color', 'rgb(255, 0, 0)'); - expect(results[0]).toHaveProperty('fillBetween', 'test:10-90:90'); - expect(results[0]).toHaveProperty('label', 'Percentile of cpu (10)'); - expect(results[0]).toHaveProperty('legend', false); + expect(results[0]).toHaveProperty('label', 'Percentile of cpu'); expect(results[0]).toHaveProperty('lines'); expect(results[0].lines).toEqual({ fill: 0.2, lineWidth: 0, show: true, + mode: 'band', }); expect(results[0]).toHaveProperty('points'); expect(results[0].points).toEqual({ show: false }); expect(results[0].data).toEqual([ - [1, 1], - [2, 1.2], + [1, 1, 5], + [2, 1.2, 5.3], ]); - expect(results[1]).toHaveProperty('id', 'test:10-90:90'); + expect(results[1]).toHaveProperty('id', 'test:50'); expect(results[1]).toHaveProperty('color', 'rgb(255, 0, 0)'); - expect(results[1]).toHaveProperty('label', 'Percentile of cpu (10)'); - expect(results[1]).toHaveProperty('legend', false); + expect(results[1]).toHaveProperty('label', 'Percentile of cpu (50)'); + expect(results[1]).toHaveProperty('stack', false); expect(results[1]).toHaveProperty('lines'); expect(results[1].lines).toEqual({ - fill: false, - lineWidth: 0, - show: true, - }); - expect(results[1]).toHaveProperty('points'); - expect(results[1].points).toEqual({ show: false }); - expect(results[1].data).toEqual([ - [1, 5], - [2, 5.3], - ]); - - expect(results[2]).toHaveProperty('id', 'test:50'); - expect(results[2]).toHaveProperty('color', 'rgb(255, 0, 0)'); - expect(results[2]).toHaveProperty('label', 'Percentile of cpu (50)'); - expect(results[2]).toHaveProperty('stack', false); - expect(results[2]).toHaveProperty('lines'); - expect(results[2].lines).toEqual({ fill: 0, lineWidth: 1, show: true, steps: false, }); - expect(results[2]).toHaveProperty('bars'); - expect(results[2].bars).toEqual({ + expect(results[1]).toHaveProperty('bars'); + expect(results[1].bars).toEqual({ fill: 0, lineWidth: 1, show: false, }); - expect(results[2]).toHaveProperty('points'); - expect(results[2].points).toEqual({ show: true, lineWidth: 1, radius: 1 }); - expect(results[2].data).toEqual([ + expect(results[1]).toHaveProperty('points'); + expect(results[1].points).toEqual({ show: true, lineWidth: 1, radius: 1 }); + expect(results[1].data).toEqual([ [1, 2.5], [2, 2.7], ]); From 969811eb207a6d78a70d62f4549fb92f7b5fc700 Mon Sep 17 00:00:00 2001 From: Steph Milovic <stephanie.milovic@elastic.co> Date: Mon, 23 Mar 2020 09:42:35 -0600 Subject: [PATCH 016/179] [SIEM] [Cases] Update case icons (#60812) --- .../public/pages/case/components/all_cases/actions.tsx | 4 ++-- .../public/pages/case/components/bulk_actions/index.tsx | 6 ++++-- .../siem/public/pages/case/components/case_view/index.tsx | 4 ++-- .../pages/case/components/user_action_tree/index.tsx | 4 ++-- .../case/components/user_action_tree/user_action_item.tsx | 6 +++--- .../components/user_action_tree/user_action_title.tsx | 8 ++++---- 6 files changed, 17 insertions(+), 15 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx index 6253d431f8401..93536077f3a4c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx @@ -32,7 +32,7 @@ export const getActions = ({ caseStatus === 'open' ? { description: i18n.CLOSE_CASE, - icon: 'magnet', + icon: 'folderCheck', name: i18n.CLOSE_CASE, onClick: (theCase: Case) => dispatchUpdate({ @@ -46,7 +46,7 @@ export const getActions = ({ } : { description: i18n.REOPEN_CASE, - icon: 'magnet', + icon: 'folderExclamation', name: i18n.REOPEN_CASE, onClick: (theCase: Case) => dispatchUpdate({ diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/index.tsx index b9da834b929ea..74a255bf5ad49 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/index.tsx @@ -27,8 +27,9 @@ export const getBulkItems = ({ caseStatus === 'open' ? ( <EuiContextMenuItem data-test-subj="cases-bulk-close-button" + disabled={selectedCaseIds.length === 0} key={i18n.BULK_ACTION_CLOSE_SELECTED} - icon="magnet" + icon="folderCheck" onClick={() => { closePopover(); updateCaseStatus('closed'); @@ -39,8 +40,9 @@ export const getBulkItems = ({ ) : ( <EuiContextMenuItem data-test-subj="cases-bulk-open-button" + disabled={selectedCaseIds.length === 0} key={i18n.BULK_ACTION_OPEN_SELECTED} - icon="magnet" + icon="folderExclamation" onClick={() => { closePopover(); updateCaseStatus('open'); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 08af603cb0dbf..0ac3adeb860ff 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -105,7 +105,7 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => title: i18n.CASE_OPENED, buttonLabel: i18n.CLOSE_CASE, status: caseData.status, - icon: 'checkInCircleFilled', + icon: 'folderCheck', badgeColor: 'secondary', isSelected: false, } @@ -115,7 +115,7 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => title: i18n.CASE_CLOSED, buttonLabel: i18n.REOPEN_CASE, status: caseData.status, - icon: 'magnet', + icon: 'folderExclamation', badgeColor: 'danger', isSelected: true, }, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index 04697e63b7451..6a3d319561353 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -78,7 +78,7 @@ export const UserActionTree = React.memo( id={DescriptionId} isEditable={manageMarkdownEditIds.includes(DescriptionId)} isLoading={isLoadingDescription} - labelAction={i18n.EDIT_DESCRIPTION} + labelEditAction={i18n.EDIT_DESCRIPTION} labelTitle={i18n.ADDED_DESCRIPTION} fullName={caseData.createdBy.fullName ?? caseData.createdBy.username} markdown={MarkdownDescription} @@ -92,7 +92,7 @@ export const UserActionTree = React.memo( id={comment.id} isEditable={manageMarkdownEditIds.includes(comment.id)} isLoading={isLoadingIds.includes(comment.id)} - labelAction={i18n.EDIT_COMMENT} + labelEditAction={i18n.EDIT_COMMENT} labelTitle={i18n.ADDED_COMMENT} fullName={comment.createdBy.fullName ?? comment.createdBy.username} markdown={ diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx index 7b99f2ef76ab3..ca73f200f1793 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -16,7 +16,7 @@ interface UserActionItemProps { id: string; isEditable: boolean; isLoading: boolean; - labelAction?: string; + labelEditAction?: string; labelTitle?: string; fullName: string; markdown: React.ReactNode; @@ -71,7 +71,7 @@ export const UserActionItem = ({ id, isEditable, isLoading, - labelAction, + labelEditAction, labelTitle, fullName, markdown, @@ -94,7 +94,7 @@ export const UserActionItem = ({ createdAt={createdAt} id={id} isLoading={isLoading} - labelAction={labelAction ?? ''} + labelEditAction={labelEditAction ?? ''} labelTitle={labelTitle ?? ''} userName={userName} onEdit={onEdit} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx index 6ad60fb9f963e..0ed081e8852f0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx @@ -25,7 +25,7 @@ interface UserActionTitleProps { createdAt: string; id: string; isLoading: boolean; - labelAction: string; + labelEditAction: string; labelTitle: string; userName: string; onEdit: (id: string) => void; @@ -35,7 +35,7 @@ export const UserActionTitle = ({ createdAt, id, isLoading, - labelAction, + labelEditAction, labelTitle, userName, onEdit, @@ -43,8 +43,8 @@ export const UserActionTitle = ({ const propertyActions = useMemo(() => { return [ { - iconType: 'documentEdit', - label: labelAction, + iconType: 'pencil', + label: labelEditAction, onClick: () => onEdit(id), }, ]; From 938ad3764024f618e01611d7162985e01796b7b5 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Mon, 23 Mar 2020 16:47:49 +0100 Subject: [PATCH 017/179] [Upgrade Assistant] Fix edge case where reindex op can falsely be seen as stale (#60770) * Fix edge case where reindex op is can falsely be seen as stale This is for multiple Kibana workers, to ensure that an item just coming off the queue is seen as "new" we set a "startedAt" field which will update the reindex op and give it the full timeout window. * Update tests to use new api too Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../plugins/upgrade_assistant/common/types.ts | 20 +++++++ .../server/lib/reindexing/error.ts | 2 + .../server/lib/reindexing/error_symbols.ts | 1 + .../server/lib/reindexing/op_utils.ts | 3 + .../server/lib/reindexing/reindex_service.ts | 59 +++++++++++++++---- .../server/lib/reindexing/worker.ts | 34 +++++++---- .../routes/reindex_indices/reindex_handler.ts | 12 +--- .../reindex_indices/reindex_indices.test.ts | 2 +- 8 files changed, 98 insertions(+), 35 deletions(-) diff --git a/x-pack/plugins/upgrade_assistant/common/types.ts b/x-pack/plugins/upgrade_assistant/common/types.ts index 1114e889882c2..6c1b24b677754 100644 --- a/x-pack/plugins/upgrade_assistant/common/types.ts +++ b/x-pack/plugins/upgrade_assistant/common/types.ts @@ -30,7 +30,27 @@ export enum ReindexStatus { export const REINDEX_OP_TYPE = 'upgrade-assistant-reindex-operation'; export interface QueueSettings extends SavedObjectAttributes { + /** + * A Unix timestamp of when the reindex operation was enqueued. + * + * @remark + * This is used by the reindexing scheduler to determine execution + * order. + */ queuedAt: number; + + /** + * A Unix timestamp of when the reindex operation was started. + * + * @remark + * Updating this field is useful for _also_ updating the saved object "updated_at" field + * which is used to determine stale or abandoned reindex operations. + * + * For now this is used by the reindex worker scheduler to determine whether we have + * A queue item at the start of the queue. + * + */ + startedAt?: number; } export interface ReindexOptions extends SavedObjectAttributes { diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts index 59922abd3e635..b1744c79bc26c 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts @@ -13,6 +13,7 @@ import { ReindexAlreadyInProgress, MultipleReindexJobsFound, ReindexCannotBeCancelled, + ReindexIsNotInQueue, } from './error_symbols'; export class ReindexError extends Error { @@ -32,6 +33,7 @@ export const error = { reindexTaskFailed: createErrorFactory(ReindexTaskFailed), reindexTaskCannotBeDeleted: createErrorFactory(ReindexTaskCannotBeDeleted), reindexAlreadyInProgress: createErrorFactory(ReindexAlreadyInProgress), + reindexIsNotInQueue: createErrorFactory(ReindexIsNotInQueue), multipleReindexJobsFound: createErrorFactory(MultipleReindexJobsFound), reindexCannotBeCancelled: createErrorFactory(ReindexCannotBeCancelled), }; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts index d5e8d643f4595..15d1b1bb9c6ae 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts @@ -11,6 +11,7 @@ export const CannotCreateIndex = Symbol('CannotCreateIndex'); export const ReindexTaskFailed = Symbol('ReindexTaskFailed'); export const ReindexTaskCannotBeDeleted = Symbol('ReindexTaskCannotBeDeleted'); export const ReindexAlreadyInProgress = Symbol('ReindexAlreadyInProgress'); +export const ReindexIsNotInQueue = Symbol('ReindexIsNotInQueue'); export const ReindexCannotBeCancelled = Symbol('ReindexCannotBeCancelled'); export const MultipleReindexJobsFound = Symbol('MultipleReindexJobsFound'); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts index dbed7de13f010..ecba02e0d5466 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts @@ -50,6 +50,9 @@ const orderQueuedReindexOperations = ({ ), }); +export const queuedOpHasStarted = (op: ReindexSavedObject) => + Boolean(op.attributes.reindexOptions?.queueSettings?.startedAt); + export const sortAndOrderReindexOperations = flow( sortReindexOperations, orderQueuedReindexOperations diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts index b270998658db8..47b7388131ff1 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts @@ -10,7 +10,6 @@ import { LicensingPluginSetup } from '../../../../licensing/server'; import { IndexGroup, - ReindexOptions, ReindexSavedObject, ReindexStatus, ReindexStep, @@ -59,7 +58,10 @@ export interface ReindexService { * @param indexName * @param opts Additional options when creating a new reindex operation */ - createReindexOperation(indexName: string, opts?: ReindexOptions): Promise<ReindexSavedObject>; + createReindexOperation( + indexName: string, + opts?: { enqueue?: boolean } + ): Promise<ReindexSavedObject>; /** * Retrieves all reindex operations that have the given status. @@ -92,7 +94,21 @@ export interface ReindexService { * @param indexName * @param opts As with {@link createReindexOperation} we support this setting. */ - resumeReindexOperation(indexName: string, opts?: ReindexOptions): Promise<ReindexSavedObject>; + resumeReindexOperation( + indexName: string, + opts?: { enqueue?: boolean } + ): Promise<ReindexSavedObject>; + + /** + * Update the update_at field on the reindex operation + * + * @remark + * Currently also sets a startedAt field on the SavedObject, not really used + * elsewhere, but is an indication that the object has started being processed. + * + * @param indexName + */ + startQueuedReindexOperation(indexName: string): Promise<ReindexSavedObject>; /** * Cancel an in-progress reindex operation for a given index. Only allowed when the @@ -544,7 +560,7 @@ export const reindexServiceFactory = ( } }, - async createReindexOperation(indexName: string, opts?: ReindexOptions) { + async createReindexOperation(indexName: string, opts?: { enqueue: boolean }) { const indexExists = await callAsUser('indices.exists', { index: indexName }); if (!indexExists) { throw error.indexNotFound(`Index ${indexName} does not exist in this cluster.`); @@ -566,7 +582,10 @@ export const reindexServiceFactory = ( } } - return actions.createReindexOp(indexName, opts); + return actions.createReindexOp( + indexName, + opts?.enqueue ? { queueSettings: { queuedAt: Date.now() } } : undefined + ); }, async findReindexOperation(indexName: string) { @@ -654,7 +673,7 @@ export const reindexServiceFactory = ( }); }, - async resumeReindexOperation(indexName: string, opts?: ReindexOptions) { + async resumeReindexOperation(indexName: string, opts?: { enqueue: boolean }) { const reindexOp = await this.findReindexOperation(indexName); if (!reindexOp) { @@ -668,16 +687,30 @@ export const reindexServiceFactory = ( } else if (op.attributes.status !== ReindexStatus.paused) { throw new Error(`Reindex operation must be paused in order to be resumed.`); } - - const reindexOptions: ReindexOptions | undefined = opts - ? { - ...(op.attributes.reindexOptions ?? {}), - ...opts, - } - : undefined; + const queueSettings = opts?.enqueue ? { queuedAt: Date.now() } : undefined; return actions.updateReindexOp(op, { status: ReindexStatus.inProgress, + reindexOptions: queueSettings ? { queueSettings } : undefined, + }); + }); + }, + + async startQueuedReindexOperation(indexName: string) { + const reindexOp = await this.findReindexOperation(indexName); + + if (!reindexOp) { + throw error.indexNotFound(`No reindex operation found for index ${indexName}`); + } + + if (!reindexOp.attributes.reindexOptions?.queueSettings) { + throw error.reindexIsNotInQueue(`Reindex operation ${indexName} is not in the queue.`); + } + + return actions.runWhileLocked(reindexOp, async lockedReindexOp => { + const { reindexOptions } = lockedReindexOp.attributes; + reindexOptions!.queueSettings!.startedAt = Date.now(); + return actions.updateReindexOp(lockedReindexOp, { reindexOptions, }); }); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts index 482b9f280ad7e..d6051ce46312f 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts @@ -6,11 +6,11 @@ import { IClusterClient, Logger, SavedObjectsClientContract, FakeRequest } from 'src/core/server'; import moment from 'moment'; import { ReindexSavedObject, ReindexStatus } from '../../../common/types'; -import { CredentialStore } from './credential_store'; +import { Credential, CredentialStore } from './credential_store'; import { reindexActionsFactory } from './reindex_actions'; import { ReindexService, reindexServiceFactory } from './reindex_service'; import { LicensingPluginSetup } from '../../../../licensing/server'; -import { sortAndOrderReindexOperations } from './op_utils'; +import { sortAndOrderReindexOperations, queuedOpHasStarted } from './op_utils'; const POLL_INTERVAL = 30000; // If no nodes have been able to update this index in 2 minutes (due to missing credentials), set to paused. @@ -128,17 +128,34 @@ export class ReindexWorker { } }; + private getCredentialScopedReindexService = (credential: Credential) => { + const fakeRequest: FakeRequest = { headers: credential }; + const scopedClusterClient = this.clusterClient.asScoped(fakeRequest); + const callAsCurrentUser = scopedClusterClient.callAsCurrentUser.bind(scopedClusterClient); + const actions = reindexActionsFactory(this.client, callAsCurrentUser); + return reindexServiceFactory(callAsCurrentUser, actions, this.log, this.licensing); + }; + private updateInProgressOps = async () => { try { const inProgressOps = await this.reindexService.findAllByStatus(ReindexStatus.inProgress); const { parallel, queue } = sortAndOrderReindexOperations(inProgressOps); - const [firstOpInQueue] = queue; + let [firstOpInQueue] = queue; - if (firstOpInQueue) { + if (firstOpInQueue && !queuedOpHasStarted(firstOpInQueue)) { this.log.debug( `Queue detected; current length ${queue.length}, current item ReindexOperation(id: ${firstOpInQueue.id}, indexName: ${firstOpInQueue.attributes.indexName})` ); + const credential = this.credentialStore.get(firstOpInQueue); + if (credential) { + const service = this.getCredentialScopedReindexService(credential); + firstOpInQueue = await service.startQueuedReindexOperation( + firstOpInQueue.attributes.indexName + ); + // Re-associate the credentials + this.credentialStore.set(firstOpInQueue, credential); + } } this.inProgressOps = parallel.concat(firstOpInQueue ? [firstOpInQueue] : []); @@ -173,14 +190,7 @@ export class ReindexWorker { } } - // Setup a ReindexService specific to these credentials. - const fakeRequest: FakeRequest = { headers: credential }; - - const scopedClusterClient = this.clusterClient.asScoped(fakeRequest); - const callAsCurrentUser = scopedClusterClient.callAsCurrentUser.bind(scopedClusterClient); - const actions = reindexActionsFactory(this.client, callAsCurrentUser); - - const service = reindexServiceFactory(callAsCurrentUser, actions, this.log, this.licensing); + const service = this.getCredentialScopedReindexService(credential); reindexOp = await swallowExceptions(service.processNextStep, this.log)(reindexOp); // Update credential store with most recent state. diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts index e640d03791cce..74c349d894839 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts @@ -8,7 +8,7 @@ import { IScopedClusterClient, Logger, SavedObjectsClientContract } from 'kibana import { LicensingPluginSetup } from '../../../../licensing/server'; -import { ReindexOperation, ReindexOptions, ReindexStatus } from '../../../common/types'; +import { ReindexOperation, ReindexStatus } from '../../../common/types'; import { reindexActionsFactory } from '../../lib/reindexing/reindex_actions'; import { reindexServiceFactory } from '../../lib/reindexing'; @@ -53,17 +53,11 @@ export const reindexHandler = async ({ const existingOp = await reindexService.findReindexOperation(indexName); - const opts: ReindexOptions | undefined = reindexOptions - ? { - queueSettings: reindexOptions.enqueue ? { queuedAt: Date.now() } : undefined, - } - : undefined; - // If the reindexOp already exists and it's paused, resume it. Otherwise create a new one. const reindexOp = existingOp && existingOp.attributes.status === ReindexStatus.paused - ? await reindexService.resumeReindexOperation(indexName, opts) - : await reindexService.createReindexOperation(indexName, opts); + ? await reindexService.resumeReindexOperation(indexName, reindexOptions) + : await reindexService.createReindexOperation(indexName, reindexOptions); // Add users credentials for the worker to use credentialStore.set(reindexOp, headers); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts index df8b2fa80a25a..e739531e0e22c 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts @@ -261,7 +261,7 @@ describe('reindex API', () => { describe('POST /api/upgrade_assistant/reindex/batch', () => { const queueSettingsArg = { - queueSettings: { queuedAt: expect.any(Number) }, + enqueue: true, }; it('creates a collection of index operations', async () => { mockReindexService.createReindexOperation From 05c995a939a8a8fea80f2e5447e5ce43648f9f07 Mon Sep 17 00:00:00 2001 From: Devon Thomson <devon.thomson@hotmail.com> Date: Mon, 23 Mar 2020 11:53:51 -0400 Subject: [PATCH 018/179] Support Histogram Data Type (#59387) Added the histogram field type to Kibana, to be used in the percentiles, percentiles ranks, and median aggregations. --- ...ugin-plugins-data-public.es_field_types.md | 1 + ...gin-plugins-data-public.kbn_field_types.md | 1 + ...ugin-plugins-data-server.es_field_types.md | 1 + ...gin-plugins-data-server.kbn_field_types.md | 1 + .../__snapshots__/field_editor.test.js.snap | 4 + .../kbn_field_types/kbn_field_types.test.ts | 1 + .../kbn_field_types_factory.ts | 5 + .../data/common/kbn_field_types/types.ts | 3 + src/plugins/data/public/public.api.md | 4 + .../public/search/aggs/metrics/cardinality.ts | 3 + .../data/public/search/aggs/metrics/median.ts | 2 +- .../search/aggs/metrics/percentile_ranks.ts | 2 +- .../public/search/aggs/metrics/percentiles.ts | 2 +- .../public/search/aggs/metrics/top_hit.ts | 4 +- src/plugins/data/server/server.api.md | 4 + .../test/functional/apps/visualize/index.ts | 1 + .../apps/visualize/precalculated_histogram.ts | 60 ++++++ .../pre_calculated_histogram/data.json | 197 ++++++++++++++++++ .../pre_calculated_histogram/mappings.json | 29 +++ 19 files changed, 321 insertions(+), 4 deletions(-) create mode 100644 x-pack/test/functional/apps/visualize/precalculated_histogram.ts create mode 100644 x-pack/test/functional/es_archives/pre_calculated_histogram/data.json create mode 100644 x-pack/test/functional/es_archives/pre_calculated_histogram/mappings.json diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.es_field_types.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.es_field_types.md index e7341caf7b3cd..c5e01715534d1 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.es_field_types.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.es_field_types.md @@ -30,6 +30,7 @@ export declare enum ES_FIELD_TYPES | GEO\_POINT | <code>"geo_point"</code> | | | GEO\_SHAPE | <code>"geo_shape"</code> | | | HALF\_FLOAT | <code>"half_float"</code> | | +| HISTOGRAM | <code>"histogram"</code> | | | INTEGER | <code>"integer"</code> | | | IP | <code>"ip"</code> | | | KEYWORD | <code>"keyword"</code> | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.kbn_field_types.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.kbn_field_types.md index e5ae8ffbd2877..30c3aa946c1ce 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.kbn_field_types.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.kbn_field_types.md @@ -23,6 +23,7 @@ export declare enum KBN_FIELD_TYPES | DATE | <code>"date"</code> | | | GEO\_POINT | <code>"geo_point"</code> | | | GEO\_SHAPE | <code>"geo_shape"</code> | | +| HISTOGRAM | <code>"histogram"</code> | | | IP | <code>"ip"</code> | | | MURMUR3 | <code>"murmur3"</code> | | | NESTED | <code>"nested"</code> | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.es_field_types.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.es_field_types.md index 81a7cbca77c48..d071955f4f522 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.es_field_types.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.es_field_types.md @@ -30,6 +30,7 @@ export declare enum ES_FIELD_TYPES | GEO\_POINT | <code>"geo_point"</code> | | | GEO\_SHAPE | <code>"geo_shape"</code> | | | HALF\_FLOAT | <code>"half_float"</code> | | +| HISTOGRAM | <code>"histogram"</code> | | | INTEGER | <code>"integer"</code> | | | IP | <code>"ip"</code> | | | KEYWORD | <code>"keyword"</code> | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kbn_field_types.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kbn_field_types.md index 40b81d2f6ac4d..a0a64190497c8 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kbn_field_types.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kbn_field_types.md @@ -23,6 +23,7 @@ export declare enum KBN_FIELD_TYPES | DATE | <code>"date"</code> | | | GEO\_POINT | <code>"geo_point"</code> | | | GEO\_SHAPE | <code>"geo_shape"</code> | | +| HISTOGRAM | <code>"histogram"</code> | | | IP | <code>"ip"</code> | | | MURMUR3 | <code>"murmur3"</code> | | | NESTED | <code>"nested"</code> | | diff --git a/src/legacy/ui/public/field_editor/__snapshots__/field_editor.test.js.snap b/src/legacy/ui/public/field_editor/__snapshots__/field_editor.test.js.snap index 6c454370f59f5..19d12f4bbbd4c 100644 --- a/src/legacy/ui/public/field_editor/__snapshots__/field_editor.test.js.snap +++ b/src/legacy/ui/public/field_editor/__snapshots__/field_editor.test.js.snap @@ -945,6 +945,10 @@ exports[`FieldEditor should show deprecated lang warning 1`] = ` "text": "_source", "value": "_source", }, + Object { + "text": "histogram", + "value": "histogram", + }, Object { "text": "conflict", "value": "conflict", diff --git a/src/plugins/data/common/kbn_field_types/kbn_field_types.test.ts b/src/plugins/data/common/kbn_field_types/kbn_field_types.test.ts index 09fc4555992a8..a3fe19fa9b2fc 100644 --- a/src/plugins/data/common/kbn_field_types/kbn_field_types.test.ts +++ b/src/plugins/data/common/kbn_field_types/kbn_field_types.test.ts @@ -87,6 +87,7 @@ describe('utils/kbn_field_types', () => { KBN_FIELD_TYPES.DATE, KBN_FIELD_TYPES.GEO_POINT, KBN_FIELD_TYPES.GEO_SHAPE, + KBN_FIELD_TYPES.HISTOGRAM, KBN_FIELD_TYPES.IP, KBN_FIELD_TYPES.MURMUR3, KBN_FIELD_TYPES.NESTED, diff --git a/src/plugins/data/common/kbn_field_types/kbn_field_types_factory.ts b/src/plugins/data/common/kbn_field_types/kbn_field_types_factory.ts index 192e8bc4f3727..cb9357eb9865e 100644 --- a/src/plugins/data/common/kbn_field_types/kbn_field_types_factory.ts +++ b/src/plugins/data/common/kbn_field_types/kbn_field_types_factory.ts @@ -95,6 +95,11 @@ export const createKbnFieldTypes = (): KbnFieldType[] => [ name: KBN_FIELD_TYPES._SOURCE, esTypes: [ES_FIELD_TYPES._SOURCE], }), + new KbnFieldType({ + name: KBN_FIELD_TYPES.HISTOGRAM, + filterable: true, + esTypes: [ES_FIELD_TYPES.HISTOGRAM], + }), new KbnFieldType({ name: KBN_FIELD_TYPES.CONFLICT, }), diff --git a/src/plugins/data/common/kbn_field_types/types.ts b/src/plugins/data/common/kbn_field_types/types.ts index 11c62e8f86dce..acd7a36b01fb3 100644 --- a/src/plugins/data/common/kbn_field_types/types.ts +++ b/src/plugins/data/common/kbn_field_types/types.ts @@ -59,6 +59,8 @@ export enum ES_FIELD_TYPES { ATTACHMENT = 'attachment', TOKEN_COUNT = 'token_count', MURMUR3 = 'murmur3', + + HISTOGRAM = 'histogram', } /** @public **/ @@ -77,4 +79,5 @@ export enum KBN_FIELD_TYPES { CONFLICT = 'conflict', OBJECT = 'object', NESTED = 'nested', + HISTOGRAM = 'histogram', } diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index dad3a8e639bc5..fac16973f92a3 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -284,6 +284,8 @@ export enum ES_FIELD_TYPES { // (undocumented) HALF_FLOAT = "half_float", // (undocumented) + HISTOGRAM = "histogram", + // (undocumented) _ID = "_id", // (undocumented) _INDEX = "_index", @@ -1126,6 +1128,8 @@ export enum KBN_FIELD_TYPES { // (undocumented) GEO_SHAPE = "geo_shape", // (undocumented) + HISTOGRAM = "histogram", + // (undocumented) IP = "ip", // (undocumented) MURMUR3 = "murmur3", diff --git a/src/plugins/data/public/search/aggs/metrics/cardinality.ts b/src/plugins/data/public/search/aggs/metrics/cardinality.ts index aa41307b2a052..88cdf3175665e 100644 --- a/src/plugins/data/public/search/aggs/metrics/cardinality.ts +++ b/src/plugins/data/public/search/aggs/metrics/cardinality.ts @@ -45,6 +45,9 @@ export const cardinalityMetricAgg = new MetricAggType({ { name: 'field', type: 'field', + filterFieldTypes: Object.values(KBN_FIELD_TYPES).filter( + type => type !== KBN_FIELD_TYPES.HISTOGRAM + ), }, ], }); diff --git a/src/plugins/data/public/search/aggs/metrics/median.ts b/src/plugins/data/public/search/aggs/metrics/median.ts index f2636d52e3484..faa0694cd5312 100644 --- a/src/plugins/data/public/search/aggs/metrics/median.ts +++ b/src/plugins/data/public/search/aggs/metrics/median.ts @@ -40,7 +40,7 @@ export const medianMetricAgg = new MetricAggType({ { name: 'field', type: 'field', - filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.DATE], + filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.DATE, KBN_FIELD_TYPES.HISTOGRAM], write(agg, output) { output.params.field = agg.getParam('field').name; output.params.percents = [50]; diff --git a/src/plugins/data/public/search/aggs/metrics/percentile_ranks.ts b/src/plugins/data/public/search/aggs/metrics/percentile_ranks.ts index 71b1c1415d98e..7dc0f70ea7b80 100644 --- a/src/plugins/data/public/search/aggs/metrics/percentile_ranks.ts +++ b/src/plugins/data/public/search/aggs/metrics/percentile_ranks.ts @@ -59,7 +59,7 @@ export const percentileRanksMetricAgg = new MetricAggType<IPercentileRanksAggCon { name: 'field', type: 'field', - filterFieldTypes: KBN_FIELD_TYPES.NUMBER, + filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.HISTOGRAM], }, { name: 'values', diff --git a/src/plugins/data/public/search/aggs/metrics/percentiles.ts b/src/plugins/data/public/search/aggs/metrics/percentiles.ts index 004918666f622..a39d68248d608 100644 --- a/src/plugins/data/public/search/aggs/metrics/percentiles.ts +++ b/src/plugins/data/public/search/aggs/metrics/percentiles.ts @@ -54,7 +54,7 @@ export const percentilesMetricAgg = new MetricAggType<IPercentileAggConfig>({ { name: 'field', type: 'field', - filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.DATE], + filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.DATE, KBN_FIELD_TYPES.HISTOGRAM], }, { name: 'percents', diff --git a/src/plugins/data/public/search/aggs/metrics/top_hit.ts b/src/plugins/data/public/search/aggs/metrics/top_hit.ts index 738de6b62bccb..d0c668c577e62 100644 --- a/src/plugins/data/public/search/aggs/metrics/top_hit.ts +++ b/src/plugins/data/public/search/aggs/metrics/top_hit.ts @@ -60,7 +60,9 @@ export const topHitMetricAgg = new MetricAggType({ name: 'field', type: 'field', onlyAggregatable: false, - filterFieldTypes: '*', + filterFieldTypes: Object.values(KBN_FIELD_TYPES).filter( + type => type !== KBN_FIELD_TYPES.HISTOGRAM + ), write(agg, output) { const field = agg.getParam('field'); output.params = {}; diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 178b2949a9456..5c231cdc05e61 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -176,6 +176,8 @@ export enum ES_FIELD_TYPES { // (undocumented) HALF_FLOAT = "half_float", // (undocumented) + HISTOGRAM = "histogram", + // (undocumented) _ID = "_id", // (undocumented) _INDEX = "_index", @@ -547,6 +549,8 @@ export enum KBN_FIELD_TYPES { // (undocumented) GEO_SHAPE = "geo_shape", // (undocumented) + HISTOGRAM = "histogram", + // (undocumented) IP = "ip", // (undocumented) MURMUR3 = "murmur3", diff --git a/x-pack/test/functional/apps/visualize/index.ts b/x-pack/test/functional/apps/visualize/index.ts index 29b1ef9870d7d..4335690b6a70e 100644 --- a/x-pack/test/functional/apps/visualize/index.ts +++ b/x-pack/test/functional/apps/visualize/index.ts @@ -13,5 +13,6 @@ export default function visualize({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./feature_controls/visualize_security')); loadTestFile(require.resolve('./feature_controls/visualize_spaces')); loadTestFile(require.resolve('./hybrid_visualization')); + loadTestFile(require.resolve('./precalculated_histogram')); }); } diff --git a/x-pack/test/functional/apps/visualize/precalculated_histogram.ts b/x-pack/test/functional/apps/visualize/precalculated_histogram.ts new file mode 100644 index 0000000000000..5d362d29b640c --- /dev/null +++ b/x-pack/test/functional/apps/visualize/precalculated_histogram.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common', 'visualize', 'discover', 'visChart', 'visEditor']); + const kibanaServer = getService('kibanaServer'); + const log = getService('log'); + + describe('pre_calculated_histogram', function() { + before(async function() { + log.debug('Starting pre_calculated_histogram before method'); + await esArchiver.load('pre_calculated_histogram'); + await kibanaServer.uiSettings.replace({ defaultIndex: 'test-histogram' }); + }); + + after(function() { + return esArchiver.unload('pre_calculated_histogram'); + }); + + const initHistogramBarChart = async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVerticalBarChart(); + await PageObjects.visualize.clickNewSearch('histogram-test'); + await PageObjects.visChart.waitForVisualization(); + }; + + const getFieldOptionsForAggregation = async (aggregation: string): Promise<string[]> => { + await PageObjects.visEditor.clickBucket('Y-axis', 'metrics'); + await PageObjects.visEditor.selectAggregation(aggregation, 'metrics'); + const fieldValues = await PageObjects.visEditor.getField(); + return fieldValues; + }; + + it('appears correctly in discover', async function() { + await PageObjects.common.navigateToApp('discover'); + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData.includes('"values": [ 0.3, 1, 3, 4.2, 4.8 ]')).to.be.ok(); + }); + + it('appears in the field options of a Percentiles aggregation', async function() { + await initHistogramBarChart(); + const fieldValues: string[] = await getFieldOptionsForAggregation('Percentiles'); + log.debug('Percentiles Fields = ' + fieldValues); + expect(fieldValues[0]).to.be('histogram-content'); + }); + + it('appears in the field options of a Percentile Ranks aggregation', async function() { + const fieldValues: string[] = await getFieldOptionsForAggregation('Percentile Ranks'); + log.debug('Percentile Ranks Fields = ' + fieldValues); + expect(fieldValues[0]).to.be('histogram-content'); + }); + }); +} diff --git a/x-pack/test/functional/es_archives/pre_calculated_histogram/data.json b/x-pack/test/functional/es_archives/pre_calculated_histogram/data.json new file mode 100644 index 0000000000000..cab1dbdf84483 --- /dev/null +++ b/x-pack/test/functional/es_archives/pre_calculated_histogram/data.json @@ -0,0 +1,197 @@ +{ + "type": "doc", + "value": { + "id": "index-pattern:histogram-test", + "index": ".kibana", + "source": { + "index-pattern": { + "title": "histogram-test", + "fields": "[{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"esTypes\":[\"_type\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"histogram-content\",\"type\":\"histogram\",\"esTypes\":[\"histogram\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"histogram-title\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]" + }, + "type": "index-pattern" + } + } +} + +{ + "type": "doc", + "value": { + "id": "5e69404d93193e4074f0ec1a", + "index": "histogram-test", + "source": { + "histogram-title": "incididunt reprehenderit mollit", + "histogram-content": { + "values": [ + 0.3, + 1, + 3, + 4.2, + 4.8 + ], + "counts": [ + 237, + 170, + 33, + 149, + 241 + ] + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "5e69408f2fc61f57fd5bc762", + "index": "histogram-test", + "source": { + "histogram-title": "culpa cillum ullamco", + "histogram-content": { + "values": [ + 0.5, + 1, + 1.2, + 1.3, + 2.8, + 3.9, + 4.3 + ], + "counts": [ + 113, + 197, + 20, + 66, + 20, + 39, + 178 + ] + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "5e6940b979b57ad343114cc3", + "index": "histogram-test", + "source": { + "histogram-title": "enim veniam et", + "histogram-content": { + "values": [ + 3.7, + 4.2 + ], + "counts": [ + 227, + 141 + ] + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "5e6940d3e95de786eeb7586d", + "index": "histogram-test", + "source": { + "histogram-title": "est incididunt sunt", + "histogram-content": { + "values": [ + 1.8, + 2.4, + 2.6, + 4.9 + ], + "counts": [ + 92, + 101, + 122, + 244 + ] + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "5e694119fb2f956a822b93b9", + "index": "histogram-test", + "source": { + "histogram-title": "qui qui tempor", + "histogram-content": { + "values": [ + 0.5, + 2.1, + 2.7, + 3, + 3.2, + 3.5, + 4.2, + 5 + ], + "counts": [ + 210, + 168, + 182, + 181, + 97, + 164, + 77, + 2 + ] + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "5e694145ad3c741aa12d6e8e", + "index": "histogram-test", + "source": { + "histogram-title": "ullamco nisi sunt", + "histogram-content": { + "values": [ + 1.7, + 4.5, + 4.8 + ], + "counts": [ + 74, + 146, + 141 + ] + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "5e694159d909d9d99b5e12d1", + "index": "histogram-test", + "source": { + "histogram-title": "magna eu incididunt", + "histogram-content": { + "values": [ + 1, + 3.4, + 4.8 + ], + "counts": [ + 103, + 205, + 11 + ] + } + } + } +} diff --git a/x-pack/test/functional/es_archives/pre_calculated_histogram/mappings.json b/x-pack/test/functional/es_archives/pre_calculated_histogram/mappings.json new file mode 100644 index 0000000000000..f616daf9d5ccb --- /dev/null +++ b/x-pack/test/functional/es_archives/pre_calculated_histogram/mappings.json @@ -0,0 +1,29 @@ +{ + "type": "index", + "value": { + "aliases": {}, + "index": "histogram-test", + "mappings": { + "properties": { + "histogram-title": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "histogram-content": { + "type": "histogram" + } + } + }, + "settings": { + "index": { + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} From cca23c26fc1ca5f439826813c6dc0eb41b12141d Mon Sep 17 00:00:00 2001 From: Brandon Kobel <brandon.kobel@elastic.co> Date: Mon, 23 Mar 2020 09:03:13 -0700 Subject: [PATCH 019/179] Adding `authc.grantAPIKeyAsInternalUser` (#60423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Parsing the Authorization HTTP header to grant API keys * Using HTTPAuthorizationHeader and BasicHTTPAuthorizationHeaderCredentials * Adding tests for grantAPIKey * Adding http_authentication/ folder * Removing test route * Using new classes to create the headers we pass to ES * No longer .toLowerCase() when parsing the scheme from the request * Updating snapshots * Update x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.ts Co-Authored-By: Aleh Zasypkin <aleh.zasypkin@gmail.com> * Updating another inline snapshot * Adding JSDoc * Renaming `grant` to `grantAsInternalUser` * Adding forgotten test. Fixing snapshot * Fixing mock * Apply suggestions from code review Co-Authored-By: Aleh Zasypkin <aleh.zasypkin@gmail.com> Co-Authored-By: Mike Côté <mikecote@users.noreply.github.com> * Using new classes for changing password * Removing unneeded asScoped call Co-authored-by: Aleh Zasypkin <aleh.zasypkin@gmail.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> Co-authored-by: Mike Côté <mikecote@users.noreply.github.com> --- .../server/authentication/api_keys.test.ts | 83 ++++++++++++++++++ .../server/authentication/api_keys.ts | 79 +++++++++++++++++ .../get_http_authentication_scheme.test.ts | 58 ------------- .../get_http_authentication_scheme.ts | 21 ----- ...p_authorization_header_credentials.test.ts | 56 ++++++++++++ ...c_http_authorization_header_credentials.ts | 44 ++++++++++ .../http_authorization_header.test.ts | 85 +++++++++++++++++++ .../http_authorization_header.ts | 45 ++++++++++ .../http_authentication/index.ts | 8 ++ .../server/authentication/index.mock.ts | 1 + .../server/authentication/index.test.ts | 18 ++++ .../security/server/authentication/index.ts | 5 ++ .../server/authentication/providers/basic.ts | 12 ++- .../server/authentication/providers/http.ts | 18 ++-- .../authentication/providers/kerberos.ts | 25 ++++-- .../server/authentication/providers/oidc.ts | 15 +++- .../server/authentication/providers/pki.ts | 12 ++- .../server/authentication/providers/saml.ts | 15 +++- .../server/authentication/providers/token.ts | 19 +++-- .../server/elasticsearch_client_plugin.ts | 18 ++++ x-pack/plugins/security/server/plugin.test.ts | 1 + .../server/routes/users/change_password.ts | 14 ++- 22 files changed, 534 insertions(+), 118 deletions(-) delete mode 100644 x-pack/plugins/security/server/authentication/get_http_authentication_scheme.test.ts delete mode 100644 x-pack/plugins/security/server/authentication/get_http_authentication_scheme.ts create mode 100644 x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.test.ts create mode 100644 x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.ts create mode 100644 x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.test.ts create mode 100644 x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.ts create mode 100644 x-pack/plugins/security/server/authentication/http_authentication/index.ts diff --git a/x-pack/plugins/security/server/authentication/api_keys.test.ts b/x-pack/plugins/security/server/authentication/api_keys.test.ts index bcb212e7bbf94..78b1d5f8e30b8 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.test.ts +++ b/x-pack/plugins/security/server/authentication/api_keys.test.ts @@ -15,6 +15,8 @@ import { } from '../../../../../src/core/server/mocks'; import { licenseMock } from '../../common/licensing/index.mock'; +const encodeToBase64 = (str: string) => Buffer.from(str).toString('base64'); + describe('API Keys', () => { let apiKeys: APIKeys; let mockClusterClient: jest.Mocked<IClusterClient>; @@ -81,6 +83,87 @@ describe('API Keys', () => { }); }); + describe('grantAsInternalUser()', () => { + it('returns null when security feature is disabled', async () => { + mockLicense.isEnabled.mockReturnValue(false); + const result = await apiKeys.grantAsInternalUser(httpServerMock.createKibanaRequest()); + expect(result).toBeNull(); + + expect(mockClusterClient.callAsInternalUser).not.toHaveBeenCalled(); + }); + + it('calls callAsInternalUser with proper parameters for the Basic scheme', async () => { + mockLicense.isEnabled.mockReturnValue(true); + mockClusterClient.callAsInternalUser.mockResolvedValueOnce({ + id: '123', + name: 'key-name', + api_key: 'abc123', + }); + const result = await apiKeys.grantAsInternalUser( + httpServerMock.createKibanaRequest({ + headers: { + authorization: `Basic ${encodeToBase64('foo:bar')}`, + }, + }) + ); + expect(result).toEqual({ + api_key: 'abc123', + id: '123', + name: 'key-name', + }); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.grantAPIKey', { + body: { + grant_type: 'password', + username: 'foo', + password: 'bar', + }, + }); + }); + + it('calls callAsInternalUser with proper parameters for the Bearer scheme', async () => { + mockLicense.isEnabled.mockReturnValue(true); + mockClusterClient.callAsInternalUser.mockResolvedValueOnce({ + id: '123', + name: 'key-name', + api_key: 'abc123', + }); + const result = await apiKeys.grantAsInternalUser( + httpServerMock.createKibanaRequest({ + headers: { + authorization: `Bearer foo-access-token`, + }, + }) + ); + expect(result).toEqual({ + api_key: 'abc123', + id: '123', + name: 'key-name', + }); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.grantAPIKey', { + body: { + grant_type: 'access_token', + access_token: 'foo-access-token', + }, + }); + }); + + it('throw error for other schemes', async () => { + mockLicense.isEnabled.mockReturnValue(true); + await expect( + apiKeys.grantAsInternalUser( + httpServerMock.createKibanaRequest({ + headers: { + authorization: `Digest username="foo"`, + }, + }) + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unsupported scheme \\"Digest\\" for granting API Key"` + ); + expect(mockClusterClient.callAsInternalUser).not.toHaveBeenCalled(); + }); + }); + describe('invalidate()', () => { it('returns null when security feature is disabled', async () => { mockLicense.isEnabled.mockReturnValue(false); diff --git a/x-pack/plugins/security/server/authentication/api_keys.ts b/x-pack/plugins/security/server/authentication/api_keys.ts index 2b1a93d907471..0d77207e390ae 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.ts +++ b/x-pack/plugins/security/server/authentication/api_keys.ts @@ -6,6 +6,8 @@ import { IClusterClient, KibanaRequest, Logger } from '../../../../../src/core/server'; import { SecurityLicense } from '../../common/licensing'; +import { HTTPAuthorizationHeader } from './http_authentication'; +import { BasicHTTPAuthorizationHeaderCredentials } from './http_authentication'; /** * Represents the options to create an APIKey class instance that will be @@ -26,6 +28,13 @@ export interface CreateAPIKeyParams { expiration?: string; } +interface GrantAPIKeyParams { + grant_type: 'password' | 'access_token'; + username?: string; + password?: string; + access_token?: string; +} + /** * Represents the params for invalidating an API key */ @@ -58,6 +67,21 @@ export interface CreateAPIKeyResult { api_key: string; } +export interface GrantAPIKeyResult { + /** + * Unique id for this API key + */ + id: string; + /** + * Name for this API key + */ + name: string; + /** + * Generated API key + */ + api_key: string; +} + /** * The return value when invalidating an API key in Elasticsearch. */ @@ -131,6 +155,39 @@ export class APIKeys { return result; } + /** + * Tries to grant an API key for the current user. + * @param request Request instance. + */ + async grantAsInternalUser(request: KibanaRequest) { + if (!this.license.isEnabled()) { + return null; + } + + this.logger.debug('Trying to grant an API key'); + const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); + if (authorizationHeader == null) { + throw new Error( + `Unable to grant an API Key, request does not contain an authorization header` + ); + } + const params = this.getGrantParams(authorizationHeader); + + // User needs `manage_api_key` or `grant_api_key` privilege to use this API + let result: GrantAPIKeyResult; + try { + result = (await this.clusterClient.callAsInternalUser('shield.grantAPIKey', { + body: params, + })) as GrantAPIKeyResult; + this.logger.debug('API key was granted successfully'); + } catch (e) { + this.logger.error(`Failed to grant API key: ${e.message}`); + throw e; + } + + return result; + } + /** * Tries to invalidate an API key. * @param request Request instance. @@ -164,4 +221,26 @@ export class APIKeys { return result; } + + private getGrantParams(authorizationHeader: HTTPAuthorizationHeader): GrantAPIKeyParams { + if (authorizationHeader.scheme.toLowerCase() === 'bearer') { + return { + grant_type: 'access_token', + access_token: authorizationHeader.credentials, + }; + } + + if (authorizationHeader.scheme.toLowerCase() === 'basic') { + const basicCredentials = BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials( + authorizationHeader.credentials + ); + return { + grant_type: 'password', + username: basicCredentials.username, + password: basicCredentials.password, + }; + } + + throw new Error(`Unsupported scheme "${authorizationHeader.scheme}" for granting API Key`); + } } diff --git a/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.test.ts b/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.test.ts deleted file mode 100644 index 6a63634394ec0..0000000000000 --- a/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.test.ts +++ /dev/null @@ -1,58 +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 { httpServerMock } from '../../../../../src/core/server/http/http_server.mocks'; - -import { getHTTPAuthenticationScheme } from './get_http_authentication_scheme'; - -describe('getHTTPAuthenticationScheme', () => { - it('returns `null` if request does not have authorization header', () => { - expect(getHTTPAuthenticationScheme(httpServerMock.createKibanaRequest())).toBeNull(); - }); - - it('returns `null` if authorization header value isn not a string', () => { - expect( - getHTTPAuthenticationScheme( - httpServerMock.createKibanaRequest({ - headers: { authorization: ['Basic xxx', 'Bearer xxx'] as any }, - }) - ) - ).toBeNull(); - }); - - it('returns `null` if authorization header value is an empty string', () => { - expect( - getHTTPAuthenticationScheme( - httpServerMock.createKibanaRequest({ headers: { authorization: '' } }) - ) - ).toBeNull(); - }); - - it('returns only scheme portion of the authorization header value in lower case', () => { - const headerValueAndSchemeMap = [ - ['Basic xxx', 'basic'], - ['Basic xxx yyy', 'basic'], - ['basic xxx', 'basic'], - ['basic', 'basic'], - // We don't trim leading whitespaces in scheme. - [' Basic xxx', ''], - ['Negotiate xxx', 'negotiate'], - ['negotiate xxx', 'negotiate'], - ['negotiate', 'negotiate'], - ['ApiKey xxx', 'apikey'], - ['apikey xxx', 'apikey'], - ['Api Key xxx', 'api'], - ]; - - for (const [authorization, scheme] of headerValueAndSchemeMap) { - expect( - getHTTPAuthenticationScheme( - httpServerMock.createKibanaRequest({ headers: { authorization } }) - ) - ).toBe(scheme); - } - }); -}); diff --git a/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.ts b/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.ts deleted file mode 100644 index b9c53f34dbcab..0000000000000 --- a/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.ts +++ /dev/null @@ -1,21 +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 { KibanaRequest } from '../../../../../src/core/server'; - -/** - * Parses request's `Authorization` HTTP header if present and extracts authentication scheme. - * https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml#authschemes - * @param request Request instance to extract authentication scheme for. - */ -export function getHTTPAuthenticationScheme(request: KibanaRequest) { - const authorizationHeaderValue = request.headers.authorization; - if (!authorizationHeaderValue || typeof authorizationHeaderValue !== 'string') { - return null; - } - - return authorizationHeaderValue.split(/\s+/)[0].toLowerCase(); -} diff --git a/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.test.ts b/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.test.ts new file mode 100644 index 0000000000000..bd3c7047e77e7 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.test.ts @@ -0,0 +1,56 @@ +/* + * 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 { BasicHTTPAuthorizationHeaderCredentials } from './basic_http_authorization_header_credentials'; + +const encodeToBase64 = (str: string) => Buffer.from(str).toString('base64'); + +describe('BasicHTTPAuthorizationHeaderCredentials.parseFromRequest()', () => { + it('parses username from the left-side of the single colon', () => { + const basicCredentials = BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials( + encodeToBase64('fOo:bAr') + ); + expect(basicCredentials.username).toBe('fOo'); + }); + + it('parses username from the left-side of the first colon', () => { + const basicCredentials = BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials( + encodeToBase64('fOo:bAr:bAz') + ); + expect(basicCredentials.username).toBe('fOo'); + }); + + it('parses password from the right-side of the single colon', () => { + const basicCredentials = BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials( + encodeToBase64('fOo:bAr') + ); + expect(basicCredentials.password).toBe('bAr'); + }); + + it('parses password from the right-side of the first colon', () => { + const basicCredentials = BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials( + encodeToBase64('fOo:bAr:bAz') + ); + expect(basicCredentials.password).toBe('bAr:bAz'); + }); + + it('throws error if there is no colon', () => { + expect(() => { + BasicHTTPAuthorizationHeaderCredentials.parseFromCredentials(encodeToBase64('fOobArbAz')); + }).toThrowErrorMatchingInlineSnapshot( + `"Unable to parse basic authentication credentials without a colon"` + ); + }); +}); + +describe(`toString()`, () => { + it('concatenates username and password using a colon and then base64 encodes the string', () => { + const basicCredentials = new BasicHTTPAuthorizationHeaderCredentials('elastic', 'changeme'); + + expect(basicCredentials.toString()).toEqual(Buffer.from(`elastic:changeme`).toString('base64')); // I don't like that this so closely mirror the actual implementation + expect(basicCredentials.toString()).toEqual('ZWxhc3RpYzpjaGFuZ2VtZQ=='); // and I don't like that this is so opaque. Both together seem reasonable... + }); +}); diff --git a/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.ts b/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.ts new file mode 100644 index 0000000000000..b8c3f1dadf1b2 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/http_authentication/basic_http_authorization_header_credentials.ts @@ -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. + */ + +export class BasicHTTPAuthorizationHeaderCredentials { + /** + * Username, referred to as the `user-id` in https://tools.ietf.org/html/rfc7617. + */ + readonly username: string; + + /** + * Password used to authenticate + */ + readonly password: string; + + constructor(username: string, password: string) { + this.username = username; + this.password = password; + } + + /** + * Parses the username and password from the credentials included in a HTTP Authorization header + * for the Basic scheme https://tools.ietf.org/html/rfc7617 + * @param credentials The credentials extracted from the HTTP Authorization header + */ + static parseFromCredentials(credentials: string) { + const decoded = Buffer.from(credentials, 'base64').toString(); + if (decoded.indexOf(':') === -1) { + throw new Error('Unable to parse basic authentication credentials without a colon'); + } + + const [username] = decoded.split(':'); + // according to https://tools.ietf.org/html/rfc7617, everything + // after the first colon is considered to be part of the password + const password = decoded.substring(username.length + 1); + return new BasicHTTPAuthorizationHeaderCredentials(username, password); + } + + toString() { + return Buffer.from(`${this.username}:${this.password}`).toString('base64'); + } +} diff --git a/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.test.ts b/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.test.ts new file mode 100644 index 0000000000000..d47a0c70f608a --- /dev/null +++ b/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServerMock } from '../../../../../../src/core/server/mocks'; + +import { HTTPAuthorizationHeader } from './http_authorization_header'; + +describe('HTTPAuthorizationHeader.parseFromRequest()', () => { + it('returns `null` if request does not have authorization header', () => { + expect( + HTTPAuthorizationHeader.parseFromRequest(httpServerMock.createKibanaRequest()) + ).toBeNull(); + }); + + it('returns `null` if authorization header value is not a string', () => { + expect( + HTTPAuthorizationHeader.parseFromRequest( + httpServerMock.createKibanaRequest({ + headers: { authorization: ['Basic xxx', 'Bearer xxx'] as any }, + }) + ) + ).toBeNull(); + }); + + it('returns `null` if authorization header value is an empty string', () => { + expect( + HTTPAuthorizationHeader.parseFromRequest( + httpServerMock.createKibanaRequest({ headers: { authorization: '' } }) + ) + ).toBeNull(); + }); + + it('parses scheme portion of the authorization header value', () => { + const headerValueAndSchemeMap = [ + ['Basic xxx', 'Basic'], + ['Basic xxx yyy', 'Basic'], + ['basic xxx', 'basic'], + ['basic', 'basic'], + // We don't trim leading whitespaces in scheme. + [' Basic xxx', ''], + ['Negotiate xxx', 'Negotiate'], + ['negotiate xxx', 'negotiate'], + ['negotiate', 'negotiate'], + ['ApiKey xxx', 'ApiKey'], + ['apikey xxx', 'apikey'], + ['Api Key xxx', 'Api'], + ]; + + for (const [authorization, scheme] of headerValueAndSchemeMap) { + const header = HTTPAuthorizationHeader.parseFromRequest( + httpServerMock.createKibanaRequest({ headers: { authorization } }) + ); + expect(header).not.toBeNull(); + expect(header!.scheme).toBe(scheme); + } + }); + + it('parses credentials portion of the authorization header value', () => { + const headerValueAndCredentialsMap = [ + ['xxx fOo', 'fOo'], + ['xxx fOo bAr', 'fOo bAr'], + // We don't trim leading whitespaces in scheme. + [' xxx fOo', 'xxx fOo'], + ]; + + for (const [authorization, credentials] of headerValueAndCredentialsMap) { + const header = HTTPAuthorizationHeader.parseFromRequest( + httpServerMock.createKibanaRequest({ headers: { authorization } }) + ); + expect(header).not.toBeNull(); + expect(header!.credentials).toBe(credentials); + } + }); +}); + +describe('toString()', () => { + it('concatenates scheme and credentials using a space', () => { + const header = new HTTPAuthorizationHeader('Bearer', 'some-access-token'); + + expect(header.toString()).toEqual('Bearer some-access-token'); + }); +}); diff --git a/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.ts b/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.ts new file mode 100644 index 0000000000000..bfc757734ec72 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/http_authentication/http_authorization_header.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaRequest } from '../../../../../../src/core/server'; + +export class HTTPAuthorizationHeader { + /** + * The authentication scheme. Should be consumed in a case-insensitive manner. + * https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml#authschemes + */ + readonly scheme: string; + + /** + * The authentication credentials for the scheme. + */ + readonly credentials: string; + + constructor(scheme: string, credentials: string) { + this.scheme = scheme; + this.credentials = credentials; + } + + /** + * Parses request's `Authorization` HTTP header if present. + * @param request Request instance to extract the authorization header from. + */ + static parseFromRequest(request: KibanaRequest) { + const authorizationHeaderValue = request.headers.authorization; + if (!authorizationHeaderValue || typeof authorizationHeaderValue !== 'string') { + return null; + } + + const [scheme] = authorizationHeaderValue.split(/\s+/); + const credentials = authorizationHeaderValue.substring(scheme.length + 1); + + return new HTTPAuthorizationHeader(scheme, credentials); + } + + toString() { + return `${this.scheme} ${this.credentials}`; + } +} diff --git a/x-pack/plugins/security/server/authentication/http_authentication/index.ts b/x-pack/plugins/security/server/authentication/http_authentication/index.ts new file mode 100644 index 0000000000000..94eb8762ecaf0 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/http_authentication/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { BasicHTTPAuthorizationHeaderCredentials } from './basic_http_authorization_header_credentials'; +export { HTTPAuthorizationHeader } from './http_authorization_header'; diff --git a/x-pack/plugins/security/server/authentication/index.mock.ts b/x-pack/plugins/security/server/authentication/index.mock.ts index c634e2c80c299..512de9626a986 100644 --- a/x-pack/plugins/security/server/authentication/index.mock.ts +++ b/x-pack/plugins/security/server/authentication/index.mock.ts @@ -13,6 +13,7 @@ export const authenticationMock = { isProviderEnabled: jest.fn(), createAPIKey: jest.fn(), getCurrentUser: jest.fn(), + grantAPIKeyAsInternalUser: jest.fn(), invalidateAPIKey: jest.fn(), isAuthenticated: jest.fn(), getSessionInfo: jest.fn(), diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 30929ba98d33b..e364dbf39db65 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -369,6 +369,24 @@ describe('setupAuthentication()', () => { }); }); + describe('grantAPIKeyAsInternalUser()', () => { + let grantAPIKeyAsInternalUser: (request: KibanaRequest) => Promise<CreateAPIKeyResult | null>; + beforeEach(async () => { + grantAPIKeyAsInternalUser = (await setupAuthentication(mockSetupAuthenticationParams)) + .grantAPIKeyAsInternalUser; + }); + + it('calls grantAsInternalUser', async () => { + const request = httpServerMock.createKibanaRequest(); + const apiKeysInstance = jest.requireMock('./api_keys').APIKeys.mock.instances[0]; + apiKeysInstance.grantAsInternalUser.mockResolvedValueOnce({ api_key: 'foo' }); + await expect(grantAPIKeyAsInternalUser(request)).resolves.toEqual({ + api_key: 'foo', + }); + expect(apiKeysInstance.grantAsInternalUser).toHaveBeenCalledWith(request); + }); + }); + describe('invalidateAPIKey()', () => { let invalidateAPIKey: ( request: KibanaRequest, diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 1eed53efc6441..8b42b2325ee1e 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -28,6 +28,10 @@ export { CreateAPIKeyParams, InvalidateAPIKeyParams, } from './api_keys'; +export { + BasicHTTPAuthorizationHeaderCredentials, + HTTPAuthorizationHeader, +} from './http_authentication'; interface SetupAuthenticationParams { http: CoreSetup['http']; @@ -169,6 +173,7 @@ export async function setupAuthentication({ getCurrentUser, createAPIKey: (request: KibanaRequest, params: CreateAPIKeyParams) => apiKeys.create(request, params), + grantAPIKeyAsInternalUser: (request: KibanaRequest) => apiKeys.grantAsInternalUser(request), invalidateAPIKey: (request: KibanaRequest, params: InvalidateAPIKeyParams) => apiKeys.invalidate(request, params), isAuthenticated: (request: KibanaRequest) => http.auth.isAuthenticated(request), diff --git a/x-pack/plugins/security/server/authentication/providers/basic.ts b/x-pack/plugins/security/server/authentication/providers/basic.ts index ad46aff8afa51..76a9f936eca48 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.ts @@ -8,7 +8,10 @@ import { KibanaRequest } from '../../../../../../src/core/server'; import { canRedirectRequest } from '../can_redirect_request'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { + HTTPAuthorizationHeader, + BasicHTTPAuthorizationHeaderCredentials, +} from '../http_authentication'; import { BaseAuthenticationProvider } from './base'; /** @@ -54,7 +57,10 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Trying to perform a login.'); const authHeaders = { - authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`, + authorization: new HTTPAuthorizationHeader( + 'Basic', + new BasicHTTPAuthorizationHeaderCredentials(username, password).toString() + ).toString(), }; try { @@ -76,7 +82,7 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - if (getHTTPAuthenticationScheme(request) != null) { + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { this.logger.debug('Cannot authenticate requests with `Authorization` header.'); return AuthenticationResult.notHandled(); } diff --git a/x-pack/plugins/security/server/authentication/providers/http.ts b/x-pack/plugins/security/server/authentication/providers/http.ts index 57163bf8145b8..6b75ae2d48156 100644 --- a/x-pack/plugins/security/server/authentication/providers/http.ts +++ b/x-pack/plugins/security/server/authentication/providers/http.ts @@ -7,7 +7,7 @@ import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base'; interface HTTPAuthenticationProviderOptions { @@ -38,7 +38,9 @@ export class HTTPAuthenticationProvider extends BaseAuthenticationProvider { if ((httpOptions?.supportedSchemes?.size ?? 0) === 0) { throw new Error('Supported schemes should be specified'); } - this.supportedSchemes = httpOptions.supportedSchemes; + this.supportedSchemes = new Set( + [...httpOptions.supportedSchemes].map(scheme => scheme.toLowerCase()) + ); } /** @@ -56,26 +58,26 @@ export class HTTPAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - const authenticationScheme = getHTTPAuthenticationScheme(request); - if (authenticationScheme == null) { + const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); + if (authorizationHeader == null) { this.logger.debug('Authorization header is not presented.'); return AuthenticationResult.notHandled(); } - if (!this.supportedSchemes.has(authenticationScheme)) { - this.logger.debug(`Unsupported authentication scheme: ${authenticationScheme}`); + if (!this.supportedSchemes.has(authorizationHeader.scheme.toLowerCase())) { + this.logger.debug(`Unsupported authentication scheme: ${authorizationHeader.scheme}`); return AuthenticationResult.notHandled(); } try { const user = await this.getUser(request); this.logger.debug( - `Request to ${request.url.path} has been authenticated via authorization header with "${authenticationScheme}" scheme.` + `Request to ${request.url.path} has been authenticated via authorization header with "${authorizationHeader.scheme}" scheme.` ); return AuthenticationResult.succeeded(user); } catch (err) { this.logger.debug( - `Failed to authenticate request to ${request.url.path} via authorization header with "${authenticationScheme}" scheme: ${err.message}` + `Failed to authenticate request to ${request.url.path} via authorization header with "${authorizationHeader.scheme}" scheme: ${err.message}` ); return AuthenticationResult.failed(err); } diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index 632a07ca2b21a..dbd0a438d71c9 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -12,7 +12,7 @@ import { } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { Tokens, TokenPair } from '../tokens'; import { BaseAuthenticationProvider } from './base'; @@ -44,13 +44,13 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - const authenticationScheme = getHTTPAuthenticationScheme(request); - if (authenticationScheme && authenticationScheme !== 'negotiate') { - this.logger.debug(`Unsupported authentication scheme: ${authenticationScheme}`); + const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); + if (authorizationHeader && authorizationHeader.scheme.toLowerCase() !== 'negotiate') { + this.logger.debug(`Unsupported authentication scheme: ${authorizationHeader.scheme}`); return AuthenticationResult.notHandled(); } - let authenticationResult = authenticationScheme + let authenticationResult = authorizationHeader ? await this.authenticateWithNegotiateScheme(request) : AuthenticationResult.notHandled(); @@ -175,7 +175,9 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { try { // Then attempt to query for the user details using the new token - const authHeaders = { authorization: `Bearer ${tokens.access_token}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', tokens.access_token).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('User has been authenticated with new access token'); @@ -205,7 +207,9 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via state.'); @@ -242,7 +246,12 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader( + 'Bearer', + refreshedTokenPair.accessToken + ).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via refreshed token.'); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index d52466826c2be..21bce028b0d98 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -10,7 +10,7 @@ import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { Tokens, TokenPair } from '../tokens'; import { AuthenticationProviderOptions, @@ -131,7 +131,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - if (getHTTPAuthenticationScheme(request) != null) { + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { this.logger.debug('Cannot authenticate requests with `Authorization` header.'); return AuthenticationResult.notHandled(); } @@ -289,7 +289,9 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via state.'); @@ -345,7 +347,12 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader( + 'Bearer', + refreshedTokenPair.accessToken + ).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via refreshed token.'); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.ts b/x-pack/plugins/security/server/authentication/providers/pki.ts index 252ab8cc67144..db022ff355702 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.ts @@ -9,7 +9,7 @@ import { DetailedPeerCertificate } from 'tls'; import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { Tokens } from '../tokens'; import { BaseAuthenticationProvider } from './base'; @@ -45,7 +45,7 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - if (getHTTPAuthenticationScheme(request) != null) { + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { this.logger.debug('Cannot authenticate requests with `Authorization` header.'); return AuthenticationResult.notHandled(); } @@ -156,7 +156,9 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via state.'); @@ -207,7 +209,9 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { try { // Then attempt to query for the user details using the new token - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('User has been authenticated with new access token'); diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index 1152ee5048699..ddf6814989a49 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -10,7 +10,7 @@ import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { canRedirectRequest } from '../can_redirect_request'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { Tokens, TokenPair } from '../tokens'; import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base'; @@ -181,7 +181,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - if (getHTTPAuthenticationScheme(request) != null) { + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { this.logger.debug('Cannot authenticate requests with `Authorization` header.'); return AuthenticationResult.notHandled(); } @@ -390,7 +390,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via state.'); @@ -445,7 +447,12 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader( + 'Bearer', + refreshedTokenPair.accessToken + ).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via refreshed token.'); diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index fffac254ed30a..91808c22c4300 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -9,7 +9,7 @@ import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { canRedirectRequest } from '../can_redirect_request'; -import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { HTTPAuthorizationHeader } from '../http_authentication'; import { Tokens, TokenPair } from '../tokens'; import { BaseAuthenticationProvider } from './base'; @@ -60,7 +60,9 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Get token API request to Elasticsearch successful'); // Then attempt to query for the user details using the new token - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Login has been successfully performed.'); @@ -82,7 +84,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - if (getHTTPAuthenticationScheme(request) != null) { + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { this.logger.debug('Cannot authenticate requests with `Authorization` header.'); return AuthenticationResult.notHandled(); } @@ -152,7 +154,9 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Trying to authenticate via state.'); try { - const authHeaders = { authorization: `Bearer ${accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via state.'); @@ -199,7 +203,12 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const authHeaders = { + authorization: new HTTPAuthorizationHeader( + 'Bearer', + refreshedTokenPair.accessToken + ).toString(), + }; const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via refreshed token.'); diff --git a/x-pack/plugins/security/server/elasticsearch_client_plugin.ts b/x-pack/plugins/security/server/elasticsearch_client_plugin.ts index 996dcb685f29b..529e8a8aa6e9c 100644 --- a/x-pack/plugins/security/server/elasticsearch_client_plugin.ts +++ b/x-pack/plugins/security/server/elasticsearch_client_plugin.ts @@ -538,6 +538,24 @@ export function elasticsearchClientPlugin(Client: any, config: unknown, componen }, }); + /** + * Grants an API key in Elasticsearch for the current user. + * + * @param {string} type The type of grant, either "password" or "access_token" + * @param {string} username Required when using the "password" type + * @param {string} password Required when using the "password" type + * @param {string} access_token Required when using the "access_token" type + * + * @returns {{api_key: string}} + */ + shield.grantAPIKey = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_security/api_key/grant', + }, + }); + /** * Invalidates an API key in Elasticsearch. * diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index a1ef352056d6a..b817bcc0858a9 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -74,6 +74,7 @@ describe('Security Plugin', () => { "createAPIKey": [Function], "getCurrentUser": [Function], "getSessionInfo": [Function], + "grantAPIKeyAsInternalUser": [Function], "invalidateAPIKey": [Function], "isAuthenticated": [Function], "isProviderEnabled": [Function], diff --git a/x-pack/plugins/security/server/routes/users/change_password.ts b/x-pack/plugins/security/server/routes/users/change_password.ts index fc3ca4573d500..aa7e8bc26cc1f 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.ts @@ -8,6 +8,10 @@ import { schema } from '@kbn/config-schema'; import { canUserChangePassword } from '../../../common/model'; import { getErrorStatusCode, wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { + HTTPAuthorizationHeader, + BasicHTTPAuthorizationHeaderCredentials, +} from '../../authentication'; import { RouteDefinitionParams } from '..'; export function defineChangeUserPasswordRoutes({ @@ -43,9 +47,13 @@ export function defineChangeUserPasswordRoutes({ ? { headers: { ...request.headers, - authorization: `Basic ${Buffer.from(`${username}:${currentPassword}`).toString( - 'base64' - )}`, + authorization: new HTTPAuthorizationHeader( + 'Basic', + new BasicHTTPAuthorizationHeaderCredentials( + username, + currentPassword || '' + ).toString() + ).toString(), }, } : request From 21e8cea183081d294b4ff323b43da87dc82d07bf Mon Sep 17 00:00:00 2001 From: Ryland Herrick <ryalnd@gmail.com> Date: Mon, 23 Mar 2020 11:10:40 -0500 Subject: [PATCH 020/179] [SIEM] Add license check to ML Rule form (#60691) * Gate ML Rules behind a license check If they don't have a Platinum or Trial license, then we disable the ML Card and provide them a link to the subscriptions marketing page. * Add aria-describedby for new ML input fields * Add data-test-subj to new ML input fields * Remove unused prop This is already passed as isLoading * Fix capitalization on translation id * Declare defaulted props as optional * Gray out entire ML card when ML Rules are disabled If we're editing an existing rule, or if the user has an insufficient license, we disable both the card and its selectability. This is more visually striking, and a more obvious CTA. --- .../anomaly_threshold_slider/index.tsx | 13 +++- .../rules/components/ml_job_select/index.tsx | 12 +++- .../components/select_rule_type/index.tsx | 60 ++++++++++++++++--- .../select_rule_type/translations.ts | 7 --- .../components/step_define_rule/index.tsx | 23 +++++-- 5 files changed, 92 insertions(+), 23 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx index 18970ff935b8d..1e18023e0c326 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx @@ -10,12 +10,16 @@ import { EuiFlexGrid, EuiFlexItem, EuiRange, EuiFormRow } from '@elastic/eui'; import { FieldHook } from '../../../../../shared_imports'; interface AnomalyThresholdSliderProps { + describedByIds: string[]; field: FieldHook; } type Event = React.ChangeEvent<HTMLInputElement>; type EventArg = Event | React.MouseEvent<HTMLButtonElement>; -export const AnomalyThresholdSlider: React.FC<AnomalyThresholdSliderProps> = ({ field }) => { +export const AnomalyThresholdSlider: React.FC<AnomalyThresholdSliderProps> = ({ + describedByIds = [], + field, +}) => { const threshold = field.value as number; const onThresholdChange = useCallback( (event: EventArg) => { @@ -26,7 +30,12 @@ export const AnomalyThresholdSlider: React.FC<AnomalyThresholdSliderProps> = ({ ); return ( - <EuiFormRow label={field.label} fullWidth> + <EuiFormRow + fullWidth + label={field.label} + data-test-subj="anomalyThresholdSlider" + describedByIds={describedByIds} + > <EuiFlexGrid columns={2}> <EuiFlexItem> <EuiRange diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx index 627fa21cc2f61..bc32162c2660b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx @@ -20,10 +20,11 @@ const JobDisplay = ({ title, description }: { title: string; description: string ); interface MlJobSelectProps { + describedByIds: string[]; field: FieldHook; } -export const MlJobSelect: React.FC<MlJobSelectProps> = ({ field }) => { +export const MlJobSelect: React.FC<MlJobSelectProps> = ({ describedByIds = [], field }) => { const jobId = field.value as string; const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); const [isLoading, siemJobs] = useSiemJobs(false); @@ -41,7 +42,14 @@ export const MlJobSelect: React.FC<MlJobSelectProps> = ({ field }) => { })); return ( - <EuiFormRow fullWidth label={field.label} isInvalid={isInvalid} error={errorMessage}> + <EuiFormRow + fullWidth + label={field.label} + isInvalid={isInvalid} + error={errorMessage} + data-test-subj="mlJobSelect" + describedByIds={describedByIds} + > <EuiFlexGroup> <EuiFlexItem> <EuiSuperSelect diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx index 229ccde54ecab..219b3d6dc4d58 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx @@ -5,19 +5,58 @@ */ import React, { useCallback } from 'react'; -import { EuiCard, EuiFlexGrid, EuiFlexItem, EuiIcon, EuiFormRow } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiCard, + EuiFlexGrid, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiLink, + EuiText, +} from '@elastic/eui'; import { FieldHook } from '../../../../../shared_imports'; import { RuleType } from '../../../../../containers/detection_engine/rules/types'; import * as i18n from './translations'; import { isMlRule } from '../../helpers'; +const MlCardDescription = ({ hasValidLicense = false }: { hasValidLicense?: boolean }) => ( + <EuiText size="s"> + {hasValidLicense ? ( + i18n.ML_TYPE_DESCRIPTION + ) : ( + <FormattedMessage + id="xpack.siem.detectionEngine.createRule.stepDefineRule.ruleTypeField.mlTypeDisabledDescription" + defaultMessage="Access to ML requires a {subscriptionsLink}." + values={{ + subscriptionsLink: ( + <EuiLink href="https://www.elastic.co/subscriptions" target="_blank"> + <FormattedMessage + id="xpack.siem.components.stepDefineRule.ruleTypeField.subscriptionsLink" + defaultMessage="Platinum subscription" + /> + </EuiLink> + ), + }} + /> + )} + </EuiText> +); + interface SelectRuleTypeProps { + describedByIds?: string[]; field: FieldHook; - isReadOnly: boolean; + hasValidLicense?: boolean; + isReadOnly?: boolean; } -export const SelectRuleType: React.FC<SelectRuleTypeProps> = ({ field, isReadOnly = false }) => { +export const SelectRuleType: React.FC<SelectRuleTypeProps> = ({ + describedByIds = [], + field, + hasValidLicense = false, + isReadOnly = false, +}) => { const ruleType = field.value as RuleType; const setType = useCallback( (type: RuleType) => { @@ -27,10 +66,15 @@ export const SelectRuleType: React.FC<SelectRuleTypeProps> = ({ field, isReadOnl ); const setMl = useCallback(() => setType('machine_learning'), [setType]); const setQuery = useCallback(() => setType('query'), [setType]); - const license = true; // TODO + const mlCardDisabled = isReadOnly || !hasValidLicense; return ( - <EuiFormRow label={field.label} fullWidth> + <EuiFormRow + fullWidth + data-test-subj="selectRuleType" + describedByIds={describedByIds} + label={field.label} + > <EuiFlexGrid columns={4}> <EuiFlexItem> <EuiCard @@ -47,11 +91,11 @@ export const SelectRuleType: React.FC<SelectRuleTypeProps> = ({ field, isReadOnl <EuiFlexItem> <EuiCard title={i18n.ML_TYPE_TITLE} - description={license ? i18n.ML_TYPE_DESCRIPTION : i18n.ML_TYPE_DISABLED_DESCRIPTION} - isDisabled={!license} + description={<MlCardDescription hasValidLicense={hasValidLicense} />} icon={<EuiIcon size="l" type="machineLearningApp" />} + isDisabled={mlCardDisabled} selectable={{ - isDisabled: isReadOnly, + isDisabled: mlCardDisabled, onClick: setMl, isSelected: isMlRule(ruleType), }} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/translations.ts index 32b860e8f703e..4dc0a89af4a49 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/translations.ts @@ -33,10 +33,3 @@ export const ML_TYPE_DESCRIPTION = i18n.translate( defaultMessage: 'Select ML job to detect anomalous activity.', } ); - -export const ML_TYPE_DISABLED_DESCRIPTION = i18n.translate( - 'xpack.siem.detectionEngine.createRule.stepDefineRule.ruleTypeField.mlTypeDisabledDescription', - { - defaultMessage: 'Access to ML requires a Platinum subscription.', - } -); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index d3ef185f3786b..cf8cc4b87b388 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -13,13 +13,14 @@ import { EuiButton, } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; -import React, { FC, memo, useCallback, useState, useEffect } from 'react'; +import React, { FC, memo, useCallback, useState, useEffect, useContext } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/public'; import { useFetchIndexPatterns } from '../../../../../containers/detection_engine/rules'; import { DEFAULT_INDEX_KEY } from '../../../../../../common/constants'; +import { MlCapabilitiesContext } from '../../../../../components/ml/permissions/ml_capabilities_provider'; import { useUiSetting$ } from '../../../../../lib/kibana'; import { setFieldValue, isMlRule } from '../../helpers'; import * as RuleI18n from '../../translations'; @@ -103,6 +104,7 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ setForm, setStepData, }) => { + const mlCapabilities = useContext(MlCapabilitiesContext); const [openTimelineSearch, setOpenTimelineSearch] = useState(false); const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(false); const [localIsMlRule, setIsMlRule] = useState(false); @@ -182,6 +184,8 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ path="ruleType" component={SelectRuleType} componentProps={{ + describedByIds: ['detectionEngineStepDefineRuleType'], + hasValidLicense: mlCapabilities.isPlatinumOrTrialLicense, isReadOnly: isUpdateView, }} /> @@ -220,7 +224,6 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ component={QueryBarDefineRule} componentProps={{ browserFields, - loading: indexPatternLoadingQueryBar, idAria: 'detectionEngineStepDefineRuleQueryBar', indexPattern: indexPatternQueryBar, isDisabled: isLoading, @@ -234,8 +237,20 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ </EuiFormRow> <EuiFormRow fullWidth style={{ display: localIsMlRule ? 'flex' : 'none' }}> <> - <UseField path="machineLearningJobId" component={MlJobSelect} /> - <UseField path="anomalyThreshold" component={AnomalyThresholdSlider} /> + <UseField + path="machineLearningJobId" + component={MlJobSelect} + componentProps={{ + describedByIds: ['detectionEngineStepDefineRulemachineLearningJobId'], + }} + /> + <UseField + path="anomalyThreshold" + component={AnomalyThresholdSlider} + componentProps={{ + describedByIds: ['detectionEngineStepDefineRuleAnomalyThreshold'], + }} + /> </> </EuiFormRow> <FormDataProvider pathsToWatch={['index', 'ruleType']}> From 91e8e3e883d51634a6b958c0ccd8d0011fdc5559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= <mikecote@users.noreply.github.com> Date: Mon, 23 Mar 2020 12:39:55 -0400 Subject: [PATCH 021/179] Adding `authc.invalidateAPIKeyAsInternalUser` (#60717) * Initial work * Fix type check issues * Fix test failures * Fix ESLint issues * Add back comment * PR feedback Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../server/authentication/api_keys.test.ts | 52 +++++++++++++++++++ .../server/authentication/api_keys.ts | 41 ++++++++++++--- .../server/authentication/index.mock.ts | 1 + .../server/authentication/index.test.ts | 23 +++++++- .../security/server/authentication/index.ts | 2 + x-pack/plugins/security/server/plugin.test.ts | 1 + 6 files changed, 111 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/security/server/authentication/api_keys.test.ts b/x-pack/plugins/security/server/authentication/api_keys.test.ts index 78b1d5f8e30b8..836740d0a547f 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.test.ts +++ b/x-pack/plugins/security/server/authentication/api_keys.test.ts @@ -225,4 +225,56 @@ describe('API Keys', () => { ); }); }); + + describe('invalidateAsInternalUser()', () => { + it('returns null when security feature is disabled', async () => { + mockLicense.isEnabled.mockReturnValue(false); + const result = await apiKeys.invalidateAsInternalUser({ id: '123' }); + expect(result).toBeNull(); + expect(mockClusterClient.callAsInternalUser).not.toHaveBeenCalled(); + }); + + it('calls callCluster with proper parameters', async () => { + mockLicense.isEnabled.mockReturnValue(true); + mockClusterClient.callAsInternalUser.mockResolvedValueOnce({ + invalidated_api_keys: ['api-key-id-1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + const result = await apiKeys.invalidateAsInternalUser({ id: '123' }); + expect(result).toEqual({ + invalidated_api_keys: ['api-key-id-1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.invalidateAPIKey', { + body: { + id: '123', + }, + }); + }); + + it('Only passes id as a parameter', async () => { + mockLicense.isEnabled.mockReturnValue(true); + mockClusterClient.callAsInternalUser.mockResolvedValueOnce({ + invalidated_api_keys: ['api-key-id-1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + const result = await apiKeys.invalidateAsInternalUser({ + id: '123', + name: 'abc', + } as any); + expect(result).toEqual({ + invalidated_api_keys: ['api-key-id-1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.invalidateAPIKey', { + body: { + id: '123', + }, + }); + }); + }); }); diff --git a/x-pack/plugins/security/server/authentication/api_keys.ts b/x-pack/plugins/security/server/authentication/api_keys.ts index 0d77207e390ae..9df7219cec334 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.ts +++ b/x-pack/plugins/security/server/authentication/api_keys.ts @@ -193,26 +193,51 @@ export class APIKeys { * @param request Request instance. * @param params The params to invalidate an API key. */ - async invalidate( - request: KibanaRequest, - params: InvalidateAPIKeyParams - ): Promise<InvalidateAPIKeyResult | null> { + async invalidate(request: KibanaRequest, params: InvalidateAPIKeyParams) { if (!this.license.isEnabled()) { return null; } - this.logger.debug('Trying to invalidate an API key'); + this.logger.debug('Trying to invalidate an API key as current user'); - // User needs `manage_api_key` privilege to use this API let result: InvalidateAPIKeyResult; try { - result = (await this.clusterClient + // User needs `manage_api_key` privilege to use this API + result = await this.clusterClient .asScoped(request) .callAsCurrentUser('shield.invalidateAPIKey', { body: { id: params.id, }, - })) as InvalidateAPIKeyResult; + }); + this.logger.debug('API key was invalidated successfully as current user'); + } catch (e) { + this.logger.error(`Failed to invalidate API key as current user: ${e.message}`); + throw e; + } + + return result; + } + + /** + * Tries to invalidate an API key by using the internal user. + * @param params The params to invalidate an API key. + */ + async invalidateAsInternalUser(params: InvalidateAPIKeyParams) { + if (!this.license.isEnabled()) { + return null; + } + + this.logger.debug('Trying to invalidate an API key'); + + let result: InvalidateAPIKeyResult; + try { + // Internal user needs `cluster:admin/xpack/security/api_key/invalidate` privilege to use this API + result = await this.clusterClient.callAsInternalUser('shield.invalidateAPIKey', { + body: { + id: params.id, + }, + }); this.logger.debug('API key was invalidated successfully'); } catch (e) { this.logger.error(`Failed to invalidate API key: ${e.message}`); diff --git a/x-pack/plugins/security/server/authentication/index.mock.ts b/x-pack/plugins/security/server/authentication/index.mock.ts index 512de9626a986..43892753f0d3f 100644 --- a/x-pack/plugins/security/server/authentication/index.mock.ts +++ b/x-pack/plugins/security/server/authentication/index.mock.ts @@ -15,6 +15,7 @@ export const authenticationMock = { getCurrentUser: jest.fn(), grantAPIKeyAsInternalUser: jest.fn(), invalidateAPIKey: jest.fn(), + invalidateAPIKeyAsInternalUser: jest.fn(), isAuthenticated: jest.fn(), getSessionInfo: jest.fn(), }), diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index e364dbf39db65..21e5f18bc0282 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -33,7 +33,7 @@ import { import { AuthenticatedUser } from '../../common/model'; import { ConfigType, createConfig$ } from '../config'; import { AuthenticationResult } from './authentication_result'; -import { setupAuthentication } from '.'; +import { Authentication, setupAuthentication } from '.'; import { CreateAPIKeyResult, CreateAPIKeyParams, @@ -410,4 +410,25 @@ describe('setupAuthentication()', () => { expect(apiKeysInstance.invalidate).toHaveBeenCalledWith(request, params); }); }); + + describe('invalidateAPIKeyAsInternalUser()', () => { + let invalidateAPIKeyAsInternalUser: Authentication['invalidateAPIKeyAsInternalUser']; + + beforeEach(async () => { + invalidateAPIKeyAsInternalUser = (await setupAuthentication(mockSetupAuthenticationParams)) + .invalidateAPIKeyAsInternalUser; + }); + + it('calls invalidateAPIKeyAsInternalUser with given arguments', async () => { + const apiKeysInstance = jest.requireMock('./api_keys').APIKeys.mock.instances[0]; + const params = { + id: '123', + }; + apiKeysInstance.invalidateAsInternalUser.mockResolvedValueOnce({ success: true }); + await expect(invalidateAPIKeyAsInternalUser(params)).resolves.toEqual({ + success: true, + }); + expect(apiKeysInstance.invalidateAsInternalUser).toHaveBeenCalledWith(params); + }); + }); }); diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 8b42b2325ee1e..c5c72853e68e1 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -176,6 +176,8 @@ export async function setupAuthentication({ grantAPIKeyAsInternalUser: (request: KibanaRequest) => apiKeys.grantAsInternalUser(request), invalidateAPIKey: (request: KibanaRequest, params: InvalidateAPIKeyParams) => apiKeys.invalidate(request, params), + invalidateAPIKeyAsInternalUser: (params: InvalidateAPIKeyParams) => + apiKeys.invalidateAsInternalUser(params), isAuthenticated: (request: KibanaRequest) => http.auth.isAuthenticated(request), }; } diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index b817bcc0858a9..a011f7e7be11e 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -76,6 +76,7 @@ describe('Security Plugin', () => { "getSessionInfo": [Function], "grantAPIKeyAsInternalUser": [Function], "invalidateAPIKey": [Function], + "invalidateAPIKeyAsInternalUser": [Function], "isAuthenticated": [Function], "isProviderEnabled": [Function], "login": [Function], From de7151e2040ed88129d58acdae69963292a83aea Mon Sep 17 00:00:00 2001 From: James Gowdy <jgowdy@elastic.co> Date: Mon, 23 Mar 2020 16:40:56 +0000 Subject: [PATCH 022/179] [ML] Disabling datafeed editing when job is running (#60751) * [ML] Disabling datafeed editing when job is running * changing variable Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../edit_job_flyout/edit_job_flyout.js | 7 +++++ .../edit_job_flyout/tabs/datafeed.js | 26 ++++++++++++++++++- .../edit_job_flyout/tabs/job_details.js | 11 ++++++++ .../server/routes/schemas/datafeeds_schema.ts | 2 +- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js index aec57e0d33cdd..29c79458fe431 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js @@ -31,6 +31,7 @@ import { withKibana } from '../../../../../../../../../src/plugins/kibana_react/ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { collapseLiteralStrings } from '../../../../../../shared_imports'; +import { DATAFEED_STATE } from '../../../../../../common/constants/states'; export class EditJobFlyoutUI extends Component { _initialJobFormState = null; @@ -41,6 +42,7 @@ export class EditJobFlyoutUI extends Component { this.state = { job: {}, hasDatafeed: false, + datafeedRunning: false, isFlyoutVisible: false, isConfirmationModalVisible: false, jobDescription: '', @@ -157,10 +159,12 @@ export class EditJobFlyoutUI extends Component { extractJob(job, hasDatafeed) { this.extractInitialJobFormState(job, hasDatafeed); + const datafeedRunning = hasDatafeed && job.datafeed_config.state !== DATAFEED_STATE.STOPPED; this.setState({ job, hasDatafeed, + datafeedRunning, jobModelMemoryLimitValidationError: '', jobGroupsValidationError: '', ...cloneDeep(this._initialJobFormState), @@ -283,6 +287,7 @@ export class EditJobFlyoutUI extends Component { jobModelMemoryLimitValidationError, isValidJobDetails, isValidJobCustomUrls, + datafeedRunning, } = this.state; const tabs = [ @@ -293,6 +298,7 @@ export class EditJobFlyoutUI extends Component { }), content: ( <JobDetails + datafeedRunning={datafeedRunning} jobDescription={jobDescription} jobGroups={jobGroups} jobModelMemoryLimit={jobModelMemoryLimit} @@ -328,6 +334,7 @@ export class EditJobFlyoutUI extends Component { datafeedScrollSize={datafeedScrollSize} jobBucketSpan={jobBucketSpan} setDatafeed={this.setDatafeed} + datafeedRunning={datafeedRunning} /> ), }, diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/datafeed.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/datafeed.js index 096a03621d422..3d81b767021a0 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/datafeed.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/datafeed.js @@ -7,7 +7,14 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { EuiFieldText, EuiForm, EuiFormRow, EuiSpacer, EuiFieldNumber } from '@elastic/eui'; +import { + EuiFieldText, + EuiForm, + EuiFormRow, + EuiSpacer, + EuiFieldNumber, + EuiCallOut, +} from '@elastic/eui'; import { calculateDatafeedFrequencyDefaultSeconds } from '../../../../../../../common/util/job_utils'; import { getNewJobDefaults } from '../../../../../services/ml_server_info'; @@ -72,9 +79,21 @@ export class Datafeed extends Component { render() { const { query, queryDelay, frequency, scrollSize, defaults } = this.state; + const { datafeedRunning } = this.props; return ( <React.Fragment> <EuiSpacer size="m" /> + {datafeedRunning && ( + <> + <EuiCallOut color="warning"> + <FormattedMessage + id="xpack.ml.jobsList.editJobFlyout.datafeed.readOnlyCalloutText" + defaultMessage="Datafeed settings cannot be edited while the datafeed is running. Please stop the job if you wish to edit these settings." + /> + </EuiCallOut> + <EuiSpacer size="l" /> + </> + )} <EuiForm> <EuiFormRow label={ @@ -90,6 +109,7 @@ export class Datafeed extends Component { value={query} onChange={this.onQueryChange} height="200px" + readOnly={datafeedRunning} /> </EuiFormRow> <EuiFormRow @@ -104,6 +124,7 @@ export class Datafeed extends Component { value={queryDelay} placeholder={defaults.queryDelay} onChange={this.onQueryDelayChange} + disabled={datafeedRunning} /> </EuiFormRow> <EuiFormRow @@ -118,6 +139,7 @@ export class Datafeed extends Component { value={frequency} placeholder={defaults.frequency} onChange={this.onFrequencyChange} + disabled={datafeedRunning} /> </EuiFormRow> <EuiFormRow @@ -132,6 +154,7 @@ export class Datafeed extends Component { value={scrollSize} placeholder={defaults.scrollSize} onChange={this.onScrollSizeChange} + disabled={datafeedRunning} /> </EuiFormRow> </EuiForm> @@ -140,6 +163,7 @@ export class Datafeed extends Component { } } Datafeed.propTypes = { + datafeedRunning: PropTypes.bool.isRequired, datafeedQuery: PropTypes.string.isRequired, datafeedQueryDelay: PropTypes.string.isRequired, datafeedFrequency: PropTypes.string.isRequired, diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/job_details.js index a609d6a7c3fba..672fd8cefaaba 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/job_details.js @@ -105,6 +105,7 @@ export class JobDetails extends Component { mmlValidationError, groupsValidationError, } = this.state; + const { datafeedRunning } = this.props; return ( <React.Fragment> <EuiSpacer size="m" /> @@ -152,6 +153,14 @@ export class JobDetails extends Component { defaultMessage="Model memory limit" /> } + helpText={ + datafeedRunning ? ( + <FormattedMessage + id="xpack.ml.jobsList.editJobFlyout.jobDetails.modelMemoryLimitLabelHelp" + defaultMessage="Model memory limit cannot be edited while the datafeed is running." + /> + ) : null + } isInvalid={mmlValidationError !== ''} error={mmlValidationError} > @@ -160,6 +169,7 @@ export class JobDetails extends Component { onChange={this.onMmlChange} isInvalid={mmlValidationError !== ''} error={mmlValidationError} + disabled={datafeedRunning} /> </EuiFormRow> </EuiForm> @@ -168,6 +178,7 @@ export class JobDetails extends Component { } } JobDetails.propTypes = { + datafeedRunning: PropTypes.bool.isRequired, jobDescription: PropTypes.string.isRequired, jobGroups: PropTypes.array.isRequired, jobModelMemoryLimit: PropTypes.string.isRequired, diff --git a/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts b/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts index ee49da6538460..466e70197e3d1 100644 --- a/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts @@ -24,7 +24,7 @@ export const datafeedConfigSchema = schema.object({ }) ), frequency: schema.maybe(schema.string()), - indices: schema.arrayOf(schema.string()), + indices: schema.maybe(schema.arrayOf(schema.string())), indexes: schema.maybe(schema.arrayOf(schema.string())), job_id: schema.maybe(schema.string()), query: schema.maybe(schema.any()), From 8143c078b6fffa9319a7809e2a6ccd30f099ac17 Mon Sep 17 00:00:00 2001 From: Andrew Cholakian <andrew@andrewvc.com> Date: Mon, 23 Mar 2020 11:54:49 -0500 Subject: [PATCH 023/179] [Uptime] Skip failing location test temporarily (#60938) --- x-pack/test/functional/apps/uptime/locations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/uptime/locations.ts b/x-pack/test/functional/apps/uptime/locations.ts index 7f6932ab50319..96c7fad89a85d 100644 --- a/x-pack/test/functional/apps/uptime/locations.ts +++ b/x-pack/test/functional/apps/uptime/locations.ts @@ -10,7 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['uptime']); - describe('location', () => { + describe.skip('location', () => { const start = new Date().toISOString(); const end = new Date().toISOString(); From 3c6666263064830cea7a4d3b9c522a3bb7feafe7 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper <Zacqary@users.noreply.github.com> Date: Mon, 23 Mar 2020 12:33:00 -0500 Subject: [PATCH 024/179] [Metrics Alerts] Remove metric field from doc count on backend (#60679) * Remove metric field from doc count on backend * Fix tests * Type fix Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../metric_threshold_executor.test.ts | 3 +- .../metric_threshold_executor.ts | 41 +++++++++++------ .../register_metric_threshold_alert_type.ts | 44 ++++++++++++++----- .../lib/alerting/metric_threshold/types.ts | 16 +++++-- .../apis/infra/metrics_alerting.ts | 35 +++++---------- 5 files changed, 86 insertions(+), 53 deletions(-) diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index a6b9b70feede2..feaa404ae960a 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -17,7 +17,7 @@ const alertInstances = new Map(); const services = { callCluster(_: string, { body }: any) { - const metric = body.query.bool.filter[1].exists.field; + const metric = body.query.bool.filter[1]?.exists.field; if (body.aggs.groupings) { if (body.aggs.groupings.composite.after) { return mocks.compositeEndResponse; @@ -228,6 +228,7 @@ describe('The metric threshold alert type', () => { comparator, threshold, aggType: 'count', + metric: undefined, }, ], }, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index 8c509c017cf20..778889ba0c7a5 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -63,6 +63,12 @@ export const getElasticsearchMetricQuery = ( groupBy?: string, filterQuery?: string ) => { + if (aggType === 'count' && metric) { + throw new Error('Cannot aggregate document count with a metric'); + } + if (aggType !== 'count' && !metric) { + throw new Error('Can only aggregate without a metric if using the document count aggregator'); + } const interval = `${timeSize}${timeUnit}`; const aggregations = @@ -108,25 +114,32 @@ export const getElasticsearchMetricQuery = ( } : baseAggs; + const rangeFilters = [ + { + range: { + '@timestamp': { + gte: `now-${interval}`, + }, + }, + }, + ]; + + const metricFieldFilters = metric + ? [ + { + exists: { + field: metric, + }, + }, + ] + : []; + const parsedFilterQuery = getParsedFilterQuery(filterQuery); return { query: { bool: { - filter: [ - { - range: { - '@timestamp': { - gte: `now-${interval}`, - }, - }, - }, - { - exists: { - field: metric, - }, - }, - ], + filter: [...rangeFilters, ...metricFieldFilters], ...parsedFilterQuery, }, }, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index 501d7549e1712..ed3a9b2f4fe36 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -17,22 +17,44 @@ export async function registerMetricThresholdAlertType(alertingPlugin: PluginSet } const alertUUID = uuid.v4(); + const baseCriterion = { + threshold: schema.arrayOf(schema.number()), + comparator: schema.oneOf([ + schema.literal('>'), + schema.literal('<'), + schema.literal('>='), + schema.literal('<='), + schema.literal('between'), + ]), + timeUnit: schema.string(), + timeSize: schema.number(), + indexPattern: schema.string(), + }; + + const nonCountCriterion = schema.object({ + ...baseCriterion, + metric: schema.string(), + aggType: schema.oneOf([ + schema.literal('avg'), + schema.literal('min'), + schema.literal('max'), + schema.literal('rate'), + schema.literal('cardinality'), + ]), + }); + + const countCriterion = schema.object({ + ...baseCriterion, + aggType: schema.literal('count'), + metric: schema.never(), + }); + alertingPlugin.registerType({ id: METRIC_THRESHOLD_ALERT_TYPE_ID, name: 'Metric Alert - Threshold', validate: { params: schema.object({ - criteria: schema.arrayOf( - schema.object({ - threshold: schema.arrayOf(schema.number()), - comparator: schema.string(), - aggType: schema.string(), - metric: schema.string(), - timeUnit: schema.string(), - timeSize: schema.number(), - indexPattern: schema.string(), - }) - ), + criteria: schema.arrayOf(schema.oneOf([countCriterion, nonCountCriterion])), groupBy: schema.maybe(schema.string()), filterQuery: schema.maybe(schema.string()), }), diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts index 07739c9d81bc4..557a071ec9175 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts @@ -25,12 +25,22 @@ export enum AlertStates { export type TimeUnit = 's' | 'm' | 'h' | 'd'; -export interface MetricExpressionParams { - aggType: MetricsExplorerAggregation; - metric: string; +interface BaseMetricExpressionParams { timeSize: number; timeUnit: TimeUnit; indexPattern: string; threshold: number[]; comparator: Comparator; } + +interface NonCountMetricExpressionParams extends BaseMetricExpressionParams { + aggType: Exclude<MetricsExplorerAggregation, 'count'>; + metric: string; +} + +interface CountMetricExpressionParams extends BaseMetricExpressionParams { + aggType: 'count'; + metric: never; +} + +export type MetricExpressionParams = NonCountMetricExpressionParams | CountMetricExpressionParams; diff --git a/x-pack/test/api_integration/apis/infra/metrics_alerting.ts b/x-pack/test/api_integration/apis/infra/metrics_alerting.ts index 09f5a498ddc00..4f17f9db67483 100644 --- a/x-pack/test/api_integration/apis/infra/metrics_alerting.ts +++ b/x-pack/test/api_integration/apis/infra/metrics_alerting.ts @@ -13,11 +13,13 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function({ getService }: FtrProviderContext) { const client = getService('legacyEs'); const index = 'test-index'; - const baseParams = { - metric: 'test.metric', - timeUnit: 'm', - timeSize: 5, - }; + const getSearchParams = (aggType: string) => + ({ + aggType, + timeUnit: 'm', + timeSize: 5, + ...(aggType !== 'count' ? { metric: 'test.metric' } : {}), + } as MetricExpressionParams); describe('Metrics Threshold Alerts', () => { before(async () => { await client.index({ @@ -30,10 +32,7 @@ export default function({ getService }: FtrProviderContext) { describe('querying the entire infrastructure', () => { for (const aggType of aggs) { it(`should work with the ${aggType} aggregator`, async () => { - const searchBody = getElasticsearchMetricQuery({ - ...baseParams, - aggType, - } as MetricExpressionParams); + const searchBody = getElasticsearchMetricQuery(getSearchParams(aggType)); const result = await client.search({ index, body: searchBody, @@ -44,10 +43,7 @@ export default function({ getService }: FtrProviderContext) { } it('should work with a filterQuery', async () => { const searchBody = getElasticsearchMetricQuery( - { - ...baseParams, - aggType: 'avg', - } as MetricExpressionParams, + getSearchParams('avg'), undefined, '{"bool":{"should":[{"match_phrase":{"agent.hostname":"foo"}}],"minimum_should_match":1}}' ); @@ -62,13 +58,7 @@ export default function({ getService }: FtrProviderContext) { describe('querying with a groupBy parameter', () => { for (const aggType of aggs) { it(`should work with the ${aggType} aggregator`, async () => { - const searchBody = getElasticsearchMetricQuery( - { - ...baseParams, - aggType, - } as MetricExpressionParams, - 'agent.id' - ); + const searchBody = getElasticsearchMetricQuery(getSearchParams(aggType), 'agent.id'); const result = await client.search({ index, body: searchBody, @@ -79,10 +69,7 @@ export default function({ getService }: FtrProviderContext) { } it('should work with a filterQuery', async () => { const searchBody = getElasticsearchMetricQuery( - { - ...baseParams, - aggType: 'avg', - } as MetricExpressionParams, + getSearchParams('avg'), 'agent.id', '{"bool":{"should":[{"match_phrase":{"agent.hostname":"foo"}}],"minimum_should_match":1}}' ); From 85481a7017cd9083e4b6288a8d7ce3febf1b2193 Mon Sep 17 00:00:00 2001 From: Alison Goryachev <alison.goryachev@elastic.co> Date: Mon, 23 Mar 2020 13:35:27 -0400 Subject: [PATCH 025/179] [UA] Upgrade assistant migration meta data can become stale (#60789) --- .../server/lib/reindexing/reindex_service.ts | 23 +++++++++++++++++++ .../server/routes/cluster_checkup.ts | 23 +++++++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts index 47b7388131ff1..1fd022bce4dcf 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts @@ -76,6 +76,12 @@ export interface ReindexService { */ findReindexOperation(indexName: string): Promise<ReindexSavedObject | null>; + /** + * Delete reindex operations for completed indices with deprecations. + * @param indexNames + */ + cleanupReindexOperations(indexNames: string[]): Promise<void> | null; + /** * Process the reindex operation through one step of the state machine and resolves * to the updated reindex operation. @@ -603,6 +609,23 @@ export const reindexServiceFactory = ( return findResponse.saved_objects[0]; }, + async cleanupReindexOperations(indexNames: string[]) { + const performCleanup = async (indexName: string) => { + const existingReindexOps = await actions.findReindexOperations(indexName); + + if (existingReindexOps && existingReindexOps.total !== 0) { + const existingOp = existingReindexOps.saved_objects[0]; + if (existingOp.attributes.status === ReindexStatus.completed) { + // Delete the existing one if its status is completed, but still contains deprecation warnings + // example scenario: index was upgraded, but then deleted and restored with an old snapshot + await actions.deleteReindexOp(existingOp); + } + } + }; + + await Promise.all(indexNames.map(performCleanup)); + }, + findAllByStatus: actions.findAllByStatus, async processNextStep(reindexOp: ReindexSavedObject) { diff --git a/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.ts b/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.ts index 22a121ab78683..fa4649f1c5dcd 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.ts @@ -7,8 +7,10 @@ import { getUpgradeAssistantStatus } from '../lib/es_migration_apis'; import { versionCheckHandlerWrapper } from '../lib/es_version_precheck'; import { RouteDependencies } from '../types'; +import { reindexActionsFactory } from '../lib/reindexing/reindex_actions'; +import { reindexServiceFactory } from '../lib/reindexing'; -export function registerClusterCheckupRoutes({ cloud, router }: RouteDependencies) { +export function registerClusterCheckupRoutes({ cloud, router, licensing, log }: RouteDependencies) { const isCloudEnabled = Boolean(cloud?.isCloudEnabled); router.get( @@ -20,6 +22,7 @@ export function registerClusterCheckupRoutes({ cloud, router }: RouteDependencie async ( { core: { + savedObjects: { client: savedObjectsClient }, elasticsearch: { dataClient }, }, }, @@ -27,8 +30,24 @@ export function registerClusterCheckupRoutes({ cloud, router }: RouteDependencie response ) => { try { + const status = await getUpgradeAssistantStatus(dataClient, isCloudEnabled); + + const callAsCurrentUser = dataClient.callAsCurrentUser.bind(dataClient); + const reindexActions = reindexActionsFactory(savedObjectsClient, callAsCurrentUser); + const reindexService = reindexServiceFactory( + callAsCurrentUser, + reindexActions, + log, + licensing + ); + const indexNames = status.indices + .filter(({ index }) => typeof index !== 'undefined') + .map(({ index }) => index as string); + + await reindexService.cleanupReindexOperations(indexNames); + return response.ok({ - body: await getUpgradeAssistantStatus(dataClient, isCloudEnabled), + body: status, }); } catch (e) { if (e.status === 403) { From 10afcf4be89cf01da3c9d942deaa2c0479be5691 Mon Sep 17 00:00:00 2001 From: MadameSheema <snootchie.boochies@gmail.com> Date: Mon, 23 Mar 2020 18:46:35 +0100 Subject: [PATCH 026/179] [SIEM] Adds 'Open one signal' Cypress test (#60484) * adds data for having closed signals * adds 'Open one signal when more than one closed signals are selected' test' Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../cypress/integration/detections.spec.ts | 299 +- .../plugins/siem/cypress/tasks/detections.ts | 6 + .../es_archives/closed_signals/data.json.gz | Bin 0 -> 55877 bytes .../es_archives/closed_signals/mappings.json | 7605 +++++++++++++++++ 4 files changed, 7787 insertions(+), 123 deletions(-) create mode 100644 x-pack/test/siem_cypress/es_archives/closed_signals/data.json.gz create mode 100644 x-pack/test/siem_cypress/es_archives/closed_signals/mappings.json diff --git a/x-pack/legacy/plugins/siem/cypress/integration/detections.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/detections.spec.ts index de17f40a3ac71..646132c3f88eb 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/detections.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/detections.spec.ts @@ -16,6 +16,7 @@ import { closeSignals, goToClosedSignals, goToOpenedSignals, + openFirstSignal, openSignals, selectNumberOfSignals, waitForSignalsPanelToBeLoaded, @@ -28,129 +29,181 @@ import { loginAndWaitForPage } from '../tasks/login'; import { DETECTIONS } from '../urls/navigation'; describe('Detections', () => { - beforeEach(() => { - esArchiverLoad('signals'); - loginAndWaitForPage(DETECTIONS); + context('Closing signals', () => { + beforeEach(() => { + esArchiverLoad('signals'); + loginAndWaitForPage(DETECTIONS); + }); + + it('Closes and opens signals', () => { + waitForSignalsPanelToBeLoaded(); + waitForSignalsToBeLoaded(); + + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .then(numberOfSignals => { + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should('eql', `Showing ${numberOfSignals} signals`); + + const numberOfSignalsToBeClosed = 3; + selectNumberOfSignals(numberOfSignalsToBeClosed); + + cy.get(SELECTED_SIGNALS) + .invoke('text') + .should('eql', `Selected ${numberOfSignalsToBeClosed} signals`); + + closeSignals(); + waitForSignals(); + cy.reload(); + waitForSignals(); + + const expectedNumberOfSignalsAfterClosing = +numberOfSignals - numberOfSignalsToBeClosed; + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .should('eq', expectedNumberOfSignalsAfterClosing.toString()); + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should('eql', `Showing ${expectedNumberOfSignalsAfterClosing.toString()} signals`); + + goToClosedSignals(); + waitForSignals(); + + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .should('eql', numberOfSignalsToBeClosed.toString()); + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should('eql', `Showing ${numberOfSignalsToBeClosed.toString()} signals`); + cy.get(SIGNALS).should('have.length', numberOfSignalsToBeClosed); + + const numberOfSignalsToBeOpened = 1; + selectNumberOfSignals(numberOfSignalsToBeOpened); + + cy.get(SELECTED_SIGNALS) + .invoke('text') + .should('eql', `Selected ${numberOfSignalsToBeOpened} signal`); + + openSignals(); + waitForSignals(); + cy.reload(); + waitForSignalsToBeLoaded(); + waitForSignals(); + goToClosedSignals(); + waitForSignals(); + + const expectedNumberOfClosedSignalsAfterOpened = 2; + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .should('eql', expectedNumberOfClosedSignalsAfterOpened.toString()); + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should( + 'eql', + `Showing ${expectedNumberOfClosedSignalsAfterOpened.toString()} signals` + ); + cy.get(SIGNALS).should('have.length', expectedNumberOfClosedSignalsAfterOpened); + + goToOpenedSignals(); + waitForSignals(); + + const expectedNumberOfOpenedSignals = + +numberOfSignals - expectedNumberOfClosedSignalsAfterOpened; + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should('eql', `Showing ${expectedNumberOfOpenedSignals.toString()} signals`); + + cy.get('[data-test-subj="server-side-event-count"]') + .invoke('text') + .should('eql', expectedNumberOfOpenedSignals.toString()); + }); + }); + + it('Closes one signal when more than one opened signals are selected', () => { + waitForSignalsToBeLoaded(); + + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .then(numberOfSignals => { + const numberOfSignalsToBeClosed = 1; + const numberOfSignalsToBeSelected = 3; + + cy.get(OPEN_CLOSE_SIGNALS_BTN).should('have.attr', 'disabled'); + selectNumberOfSignals(numberOfSignalsToBeSelected); + cy.get(OPEN_CLOSE_SIGNALS_BTN).should('not.have.attr', 'disabled'); + + closeFirstSignal(); + cy.reload(); + waitForSignalsToBeLoaded(); + waitForSignals(); + + const expectedNumberOfSignals = +numberOfSignals - numberOfSignalsToBeClosed; + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .should('eq', expectedNumberOfSignals.toString()); + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should('eql', `Showing ${expectedNumberOfSignals.toString()} signals`); + + goToClosedSignals(); + waitForSignals(); + + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .should('eql', numberOfSignalsToBeClosed.toString()); + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should('eql', `Showing ${numberOfSignalsToBeClosed.toString()} signal`); + cy.get(SIGNALS).should('have.length', numberOfSignalsToBeClosed); + }); + }); }); - - it('Closes and opens signals', () => { - waitForSignalsPanelToBeLoaded(); - waitForSignalsToBeLoaded(); - - cy.get(NUMBER_OF_SIGNALS) - .invoke('text') - .then(numberOfSignals => { - cy.get(SHOWING_SIGNALS) - .invoke('text') - .should('eql', `Showing ${numberOfSignals} signals`); - - const numberOfSignalsToBeClosed = 3; - selectNumberOfSignals(numberOfSignalsToBeClosed); - - cy.get(SELECTED_SIGNALS) - .invoke('text') - .should('eql', `Selected ${numberOfSignalsToBeClosed} signals`); - - closeSignals(); - waitForSignals(); - cy.reload(); - waitForSignals(); - waitForSignalsToBeLoaded(); - - const expectedNumberOfSignalsAfterClosing = +numberOfSignals - numberOfSignalsToBeClosed; - cy.get(NUMBER_OF_SIGNALS) - .invoke('text') - .should('eq', expectedNumberOfSignalsAfterClosing.toString()); - cy.get(SHOWING_SIGNALS) - .invoke('text') - .should('eql', `Showing ${expectedNumberOfSignalsAfterClosing.toString()} signals`); - - goToClosedSignals(); - waitForSignals(); - - cy.get(NUMBER_OF_SIGNALS) - .invoke('text') - .should('eql', numberOfSignalsToBeClosed.toString()); - cy.get(SHOWING_SIGNALS) - .invoke('text') - .should('eql', `Showing ${numberOfSignalsToBeClosed.toString()} signals`); - cy.get(SIGNALS).should('have.length', numberOfSignalsToBeClosed); - - const numberOfSignalsToBeOpened = 1; - selectNumberOfSignals(numberOfSignalsToBeOpened); - - cy.get(SELECTED_SIGNALS) - .invoke('text') - .should('eql', `Selected ${numberOfSignalsToBeOpened} signal`); - - openSignals(); - waitForSignals(); - cy.reload(); - waitForSignalsToBeLoaded(); - waitForSignals(); - goToClosedSignals(); - waitForSignals(); - - const expectedNumberOfClosedSignalsAfterOpened = 2; - cy.get(NUMBER_OF_SIGNALS) - .invoke('text') - .should('eql', expectedNumberOfClosedSignalsAfterOpened.toString()); - cy.get(SHOWING_SIGNALS) - .invoke('text') - .should('eql', `Showing ${expectedNumberOfClosedSignalsAfterOpened.toString()} signals`); - cy.get(SIGNALS).should('have.length', expectedNumberOfClosedSignalsAfterOpened); - - goToOpenedSignals(); - waitForSignals(); - - const expectedNumberOfOpenedSignals = - +numberOfSignals - expectedNumberOfClosedSignalsAfterOpened; - cy.get(SHOWING_SIGNALS) - .invoke('text') - .should('eql', `Showing ${expectedNumberOfOpenedSignals.toString()} signals`); - - cy.get('[data-test-subj="server-side-event-count"]') - .invoke('text') - .should('eql', expectedNumberOfOpenedSignals.toString()); - }); - }); - - it('Closes one signal when more than one opened signals are selected', () => { - waitForSignalsToBeLoaded(); - - cy.get(NUMBER_OF_SIGNALS) - .invoke('text') - .then(numberOfSignals => { - const numberOfSignalsToBeClosed = 1; - const numberOfSignalsToBeSelected = 3; - - cy.get(OPEN_CLOSE_SIGNALS_BTN).should('have.attr', 'disabled'); - selectNumberOfSignals(numberOfSignalsToBeSelected); - cy.get(OPEN_CLOSE_SIGNALS_BTN).should('not.have.attr', 'disabled'); - - closeFirstSignal(); - cy.reload(); - waitForSignalsToBeLoaded(); - waitForSignals(); - - const expectedNumberOfSignals = +numberOfSignals - numberOfSignalsToBeClosed; - cy.get(NUMBER_OF_SIGNALS) - .invoke('text') - .should('eq', expectedNumberOfSignals.toString()); - cy.get(SHOWING_SIGNALS) - .invoke('text') - .should('eql', `Showing ${expectedNumberOfSignals.toString()} signals`); - - goToClosedSignals(); - waitForSignals(); - - cy.get(NUMBER_OF_SIGNALS) - .invoke('text') - .should('eql', numberOfSignalsToBeClosed.toString()); - cy.get(SHOWING_SIGNALS) - .invoke('text') - .should('eql', `Showing ${numberOfSignalsToBeClosed.toString()} signal`); - cy.get(SIGNALS).should('have.length', numberOfSignalsToBeClosed); - }); + context('Opening signals', () => { + beforeEach(() => { + esArchiverLoad('closed_signals'); + loginAndWaitForPage(DETECTIONS); + }); + + it('Open one signal when more than one closed signals are selected', () => { + waitForSignals(); + goToClosedSignals(); + waitForSignalsToBeLoaded(); + + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .then(numberOfSignals => { + const numberOfSignalsToBeOpened = 1; + const numberOfSignalsToBeSelected = 3; + + cy.get(OPEN_CLOSE_SIGNALS_BTN).should('have.attr', 'disabled'); + selectNumberOfSignals(numberOfSignalsToBeSelected); + cy.get(OPEN_CLOSE_SIGNALS_BTN).should('not.have.attr', 'disabled'); + + openFirstSignal(); + cy.reload(); + goToClosedSignals(); + waitForSignalsToBeLoaded(); + waitForSignals(); + + const expectedNumberOfSignals = +numberOfSignals - numberOfSignalsToBeOpened; + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .should('eq', expectedNumberOfSignals.toString()); + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should('eql', `Showing ${expectedNumberOfSignals.toString()} signals`); + + goToOpenedSignals(); + waitForSignals(); + + cy.get(NUMBER_OF_SIGNALS) + .invoke('text') + .should('eql', numberOfSignalsToBeOpened.toString()); + cy.get(SHOWING_SIGNALS) + .invoke('text') + .should('eql', `Showing ${numberOfSignalsToBeOpened.toString()} signal`); + cy.get(SIGNALS).should('have.length', numberOfSignalsToBeOpened); + }); + }); }); }); diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts b/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts index 3416e3eb81de3..abea4a887b8ba 100644 --- a/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts +++ b/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts @@ -40,6 +40,12 @@ export const goToOpenedSignals = () => { cy.get(OPENED_SIGNALS_BTN).click({ force: true }); }; +export const openFirstSignal = () => { + cy.get(OPEN_CLOSE_SIGNAL_BTN) + .first() + .click({ force: true }); +}; + export const openSignals = () => { cy.get(OPEN_CLOSE_SIGNALS_BTN).click({ force: true }); }; diff --git a/x-pack/test/siem_cypress/es_archives/closed_signals/data.json.gz b/x-pack/test/siem_cypress/es_archives/closed_signals/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..117c829b31d6e4fad894f589c5244678206ec21a GIT binary patch literal 55877 zcmaf)WmsIzllGI~0fJj_mk`{YAi+JjyE{Yh;O;JiYY6ThB)H4q7Cg8+yhCLF+1=;a zm)~{G%m;e<RCjev)qUy^hQdC3`g?)4x9B2^I{>1(dmJeUx|n*yyCrTp%|k@26@Xyq z^9<*(CBz)kw*pv;=>2}HsjYYjnIXSIh^>4-zDoARK||AL*OH9l;>Rr=+4Q?9{)7BB z%=9}9?$t7r4Vb`ERx18$!Ye3Gt8RPqn2z}*|8PEBOav1eGHHX0xhsKY#$=}V;j3ia z=~kfOrmwyy3L-Rnwo@B(bBy=2DyoO96M`kv3uaBqWtfTpU*$H@e*QD}O>Gl7Kb@u* z8Fz=Cr$pyB2bmhBz9^#Re$!No6vl#|p{REyW^GIcI`6D0Yi<*jpAqIASaXT_TX^tu zSBf>)icZ(F<Wo3Cv2F#Iev9(Y(S)<O|MpVvdQqlq835DipiI!YVOC%fmhzFhy4VQK zN?I7nBCZ>xPcaum6%%)MqOhg%6;Z$HFm_ib{bN#ycGU!3!EWQ{*tTJc`>pTTirZSe z9g?A=jo3}St2Gnf-#uC;pFE>K6m;~Mmb|x%y;!wRPY*m{(oVM@4fX}?aB>}-uhMGG z2d*}j9=t;Vq~4?7`0CfM%jUGcVO(rTRA8w2EG#{rxA9ujKRzX^Sdx{vcf8VtSVcu; zHa@T4=gf=FGf=kB$mz2baI&bGg;J`oFUlV(QC}UIV#_}bRxLBG!mdhwweQ71+FM8} zX?VU94r?|UOI2j^dbT_)zfJuGOYik^UA;QYY%J?`Y~!9G<H}d?bi&Z_#5YhBFYb5s zOCO!DPwE+57wBjakSz5+%E9Nn-<b4Sg%|HUrWdPQnd&u^=S)_jM>H|}_!={yKv(Y! zFG=YDz0S6F1sMyrBwgw=<-m`=I%~H*xRxR%Kq*N2stYNlL{C0%lbl62$G-=kMWd%v zxeMd{(8W~IZINjKXs5$Ti;#2l)Z^n;N9=hCrB12Leyt|IbBJ#$$fIHEQziY6AJfqf zTk8A%%JneeyBkBi)D~N@Cy+eXWi{dcl!<S~+cH?(WT6)+%OPZYW49yRM$+X}b>+Uc zUGxc7@opC^l>wR(LpnF(^B)zsT@*v@K`*te6z85FU;(q)6&C1qVk*NW7a!;b-(_gs z-&M<BSJqY7-I<$R9}O9Ag)f^m&V?R&g%7)U8Z?=Hv*^5NR9?#c;Wtg);_BR_TFGHS z<saQQ_H~sH#Zek^(;|Ib>2wyy8m=NOX3S_rw$IGh(`?SGJ7HXAY{9I9^TzWYTFwc+ zuo+IX74b}qUkm6s;juFA)YPoAvG1`51PXC7?agm|o^tw@<csY_PT3OC=7!<*_<G3^ ziarBa6|ciNDEGBB?`rY9CylOwNEnEAzW*bCIPtjig1knvY#D(xk@NiB*j{>~Z^^{b z9^B+mv}F3+aGipS^6fXGa2CB1?VF|jvy}Tyz1Jll9}j;t4=M9w)D0>+281g&IVe@- zUQQ>$bKjfU5a1!)Y!H9_-ic43f$%UgHMiLaQ}bAQ4nKVW1jRoRlti|L4AqDi;X7w~ zxAHw77JS_%Htm1!*!d&#-r5N;oWJ<-AjOKKC9qTUn4Zt|K)tkunZu&%5~hfQps4X+ z>$2*q`HQTdCNSm3I#*7+QTc2|!hq$1&B<Z)2g1#w;G>&UsjX8qVdp)+1@OlP1?GcW zZb*JwW;<+MTyOU5{NoZlP!E=-uSVT7f%RzHih*&;-uAhKCL3!kcFC-14SSy}&fV1h zWo7`@(1R_d1~4aD`Rl&Yp|V%qm*%M|$6@onE9lLh^EW)(-5Re^MAqw$2b5Sy+DPEI zYI)z-p!uHIKE5oGL3$m%>wGfd_HByWMXg3OwcSFz%P_dhj0m)I*0X3s^x*d>rWHZp z=Jev^R=oF0IF}12nS?l*ggTjoF`0xjnMJTcIk$&`294|u8W}Ab*;_O+Iy5qRG%^M> zGDb8qCNwhUI&zjZy00~mn-?q_7fKr!jz%$Y8=&fqxt0RUS6ST|j&ntxt=I8e^InTA zvXmyUuDdNtnD0tUmtF*huc|?iRF94`<l%G$7<3+mqW{p@@&ZTc$Lr~~2NQdrs@dpF zKZI_PLyvJfHv?v7&H!}#@)$`1AHbaPbH(s!DF*ZUEI7<8G22IMA4&dHTMes2_d`s4 zSXEbc+l8c}g1|#&c#x2zetbY@9i-=X&5qzG<V01>O8Bacj}CkExCMDDeFHKk1@Y-$ z_TC|+r#DNKSkt86O;fDnBVS@ZdY<UuS`-^e=54oST&+?15=ar4OIKYQp03?qS{&*; ze6HCNu~_fW4pn$Gr*F}5`t+h;A^6Iyg?3Xgx4OSJD<cAWib!eNs7;{B$pJ!4k- zh?luL7cWjrfcKZC;Vi2{#o^ZlFszL}7J;r#owl5yI%|#fA2o$j{m^<99I0GfjO>bU z-Vw|=mduq}^eC(6zJ9H4g`qN1xH1Pz$5JtAPuh^Mhm$94CSHU*h*|_$qrj6Bm0-@+ zc)|2mLu!9+U}KCr$^m0&wAf!6E#eC(ER?4)sXt-jgbA?%1hNd!i8Yo$$x$EyIKJJX zOiMi31C`bh7BC|?+@|23ao3R4aM48!j}hQaz6`okl*gr@sHmf|Rvi$h;i?(kAy{Vc zDB%9I#<h*!!^+<qmEArFBgC_fUVc7tJ0jimaV2Gcu6%OR-KQUl8_{ZewVWY>&fb%e z9?080R71Ku`AXVq^Z*NjqZyVPqGVgJYI%Y4?P|{HB|z(WRXpe2xtT5DF`RW#fre(B zYcqZ)OE3J$fqzNa?Z`yp{U!H(?ahUg*P6XSf@i*M7ACXm>BF2;%i`F@>tP1`C0Bp6 zC5}WU#r8sM#i*|ClU<QlPW`qfiWHpT?A52WE+N33SM*}D+S(6CXNvv7jVWW8_Z6#A zx=y7#7<II=4lXv4LoW`Zfr_=JVd6f7iQXBSH4~a{E({aU+9<w@nEv<HcmV!5IzugZ zdliYc5)Ps~(%<mY<|Udf1+@yqX^HktzwQ8LGjD6t2NkFUN2i9as`Yy&qtYL)*PlPA z@6NnH?1?|Us=1$=f!y&L!GEOzYCGgO9CRhV5(onYLy?*lxdlY8lL^<1*}bk>p>fN| zF>H@VDV+cbu4hs)2Qx%|2?WF5^u<{o;Psr~I`)%_O;|BLxo$38{rA{cw})xg?r&w- zhTPs*Z-yMt7fD^Wtn?}1XNw0*NC%0f=hKSUwo|_IQ0n3o{H*v;k=i15(09I`4lh9= zanMjI*-s&Mz<(jZO<ne3O@eI_dnjB+Qf~i?Ij=qHRbvR*Co%>XI|0<cDI|I>WR%(A zQdZ9#b*R{1loQsE7wouXW~LN1sQQBgBcQ3P8E%Z?gYncL5!@4lba2g|uy=B^10$=w zbdWMuYi;&)Y&+A#cQI`{zj#%XzWSgb){Wy^J1#*1<sv%J$|)-3@=5>wmw=Uo<xvz$ zHn&b~3LJGfs+E>+bqCl!1()<0R`IiA*K}>$=o)fxYzh^Ot^7kn_^vC}lZ~ZqO&@nT zmv|20CO$>(mTrZpZO1xn;*`q-Ww_p}e+q@2Dg_<x6<lOv+6t|_6BDY|xBbun%v3~T zT^|OzigF#;GK!A?jZXLFDkfcB7pT-5+IY-HfNo#p<L5Aa`xh8VO+U2}vqUN6$DzX+ zQQ{V|MZ9nKD+blV0lqIR<>F6NbRYF`ff!8L@{+wHSas^;$K=K+=wB6@{tZURrjK1$ z5oDH6aGLsI3=+*c9+CDDnOZzvmiI;>%i1_9PDvCWWis{11wr7KaYzhAF#Dbu`$P$F z1~jc1e4VF~BUgk|RV8|yrp;D29T+`{inkc`-ZsF^>=sGc70r}2d`JePYy3<1VFd$= z(S1OOr@^{_D2oAECRc`wVQKXITGLcXjj#Pi(G`#NMRXjQ<12|M)4ZGOAG;>E_S~s^ z9eIltBcT~hiz|s9Z9dWjCrZlW!RHCsi6lm&Oz-K?*@pwakSmU6qN3nAGx(O|$A*OK zM8OdXQJ}fN=^;}^s~yGgo}r$_^o2sW!u^G~f1+4ISR^e?JTH8l2`~(`eLJm2j#^E; znk;~moSda=*k#K@PMrCDr4(e#6NZ46+-9$fMxbYLL)HA_H}bT*&ieHqc2Y#>^copn z%>~e(j}E*OE6+3EF;-rpz3{K5E+<1Uk{=KwsS8#R1%}C(h}PsIq|iDK#454d+%8aZ zT=QnEz~jv^_M5a*+Ge7k1yhGW+(7<?@YI`L^a?*pI?E#(voa?pG80i)MJ}^d)blxE zBXo8)BTQ;m@nhtHRRuDk3;{4nW*HoZe&zE#l5lP{8H=_Tr%<X4ZnC;|JJgYk$&Yyp z593T_pwA}vl@<1ULTd>SrJa#EkwFWUy^z@14w7Cj_dCTqSERE`tZn|pk6)RYiL2wn z-f0DJBL^=u4Au{iQJeZR=R(Se)@wmo%~*{x9S^4sz*n`srOzGSqva7lMeNBDbV<(U z$ZY-_rC#{8S<3H4o8mp&vBP9SQBg$`gB)f4Ya|~JS*D^DOtLJYhDp(aXOZ1ST?D}- zYq_{AdpF(C>o9sPZ!>qk4adFJ=g@o6`qB03d#ARwCHH})L({e6xFDDH>`^Y)3bFZ7 z7jm)-Iv1+xO{Vk^i5!KN?YKnF2jnR(TdP<sXkE|gXGujGGzGYQ7Yk1PXx~%zPfm5L z85erGX{xa!wGnOa5S4tv>;L)rUkHBi3dgowA2j?NtJvS2gyxMoO|cVS0i6wP5EBN% ze&@51@;*J@nM@>O?VH7<Jfa+!%`1;7gk=@p<-_9ijA{AMTX%d`QvDYUZmcsm^;O^5 zPPBjAeH=4gyDcQV@tEAZTz)@<iOn)4HTcei5Dyx9);i?-C(FRUP??$ygzOaR6}O|# zY9R-S9`a(y2g9QHQ2EQelrS{};Yv5d^=p{NoOZzY{~p!@TZvz}s9BEh<!;Ho-8Q$d zxQJ)@gPo9%b|0T)o|j`jn!6mo#!1{n?`+eCgA28C5&hfq*;jv2>a!mw*>8s{%+x=Y z6l*AP6$MEa#-ys>BE~6UOg<I1k?X?-68}~i5kc-QiUb~L9WZOXQLhpAn7;M7K%yY` zCP#w(;Ftjmpjc*Cdk>9%$;JJ+FREMLShvQp@&ivr7PJUh>n{f38g;=Gpn{<FG-zIz z<l8uEK!`=SB2x91%wX3howqDGwOZt*UcwLW5GeU*8?^0c&9i!f`ropuoAvmwg}y~F zK{UB_e^~tnO{tsdtmB+fd#l55J*q-Kv=#pn+7&Xf6xI6UWW*7fpCOMzA~v(=8%Zmr zIr(=`tHhmt=JgFO^CmJ7tlt|aV82~K$_TAss$luZT;gr4ldo2+MRYz-LUzuldKJX| zHy<Ju)VqCGV^AT6N*&)(2vOT16U%i}g-<%bObOwj6h$aA=A-5IE(z7hBM{~ZF9<Fq zlP)xXa3vY<k!;g7Tt6*voGP_^o&&0n)Z`rA;Q<u@AABU8G<N~&L$=MZ4hdVuwGdVW zaa-!qo(L(^I_28KN(0vXQ_w@NH8<x3L!?!9&x!pwk}72r<t1?IRkQcPvayN3C$lPP zNQY4<;L-#ousd#t3@w#&D+aJ30P5=MKjd3(QSOKo%=n}<M2CdiHJE<zVyH*2DcwD! zdk|Dfz5Dz%E_Mw#Y?`l8>?G?J|Mp>VZh9}^<k7%yq;tV2bCk~QV-450?~fsz6=cR) zIrGW1i155|kLsVsUn1(3EYHgBi_j#k%5kkmNW_yIg;t|E#>b*X8EsEo5{8uFn(tiK zIdd1^yHO7CkG}kHDG*C+WM%nEoU+I)Q&R$HkQ?DD2sZ(uAn#*|Qd_)4cpcZ2l>Y_( zqRF81KH2qZdljuGO6V#rn}f)m<S&%JnyiB$>0t}nse$p85s-q}(Q56>Ahq6*9P}}G zD5rL}H~8?~qFX#u-lRW=4QrLLv4ytG4Qo1#+SK;X-y(ed-oOa#Ti!XYQEcPUh=m}Y zT#l)HM{_fz$=91?UPMKdqeH70fLp<q4^9{$=668aCB|V)1||7Qf^o$)C;?K>^baqS zTpT8OXCIDku63P`5O0qb=K>=ai;yx3aXZGEJ~zX|=m;IAfMcR_)?a-1sKP+uu%V=z zAgX7XKWQZDvECkq=L;KEm7J-S$?3Q5=bgauy1hA)RwQuA9}H{L(z%%%YrL;Gh7Ox@ zt&C!a&NlfYm8%uvK!=F2n>Y*~tHBRTXRf!)*PPb91$o_NExrw9hxYd->|s6~NwXB? zC@yE!(&_1zKpwA@jCtj$)9}cZj}%I%eOUzXc{!grG*Xapa(qPSn21Tt)^JqFVm0E{ z6Y%Q=P_Eq$!^q%cK>NZnC)=zLJ-P3OlEC&D|BaGqKpo8+Sae}Em_oo(fuRqw@(-fs zC^kl$kDCg1^p+POvoeGaYE4wZOocd4oKCoHZFKiY%F>2-b)kiB@X4Me$#E{vJn(Fw zX)PY}nh@Azq^G*Ar4bB^px0a(?|dH_9?#>~8F}z%W!W6Lu?5LJkkj#L?rYxFA6tM5 z5)*H2D~dMqLeKdhYx+)i-bi9iEZ?PgIx>2kPoEId<<pmGNqHSxT#vNnA8JZ@U0Ylq zSv1xswO&|UZ+<(AN5Y!Wc~oybwQ$|iznRw=K=Ryw474k-Kf9w#EC9GTmNHmh*Vqk| zg=w|2TVG$24$wzl?^qU<kq#gwy0Thd$5#wEcfWlEESwY*meRHH<e#P8U)dX8uSO2M zUrSiVsk=-4IXXRz_R(qK+>P_#=Ivovlc!FPR$Jm)>GIA=i`@WH*>c>7KTURZ_8bHG zTeO(D0hEpKu-na}MYpEQ*4y)y`_<vsUJuVk+#ig&wc#2PLUUcDyp7)HC~&%v7{HF& zQugO0FutS3n{kwNjW}rcUk_<=bj@)ZIwl^Wp}62^#AiAN$NFUKzo_>K4zBR_7e<^& zcg=!Azv}wvIG&51jqJ^#7Vt8F)3_m+4t0aG!cW|QgKs@pDolz8M#`PI^X|C!2!kw1 zQ@kwFhz$?oLAIGr*Kul<>LGe+sg#4h^J2Pv)%DP!U?P5~fbfCtTf<J8iaJ6KZB=2t zZG`YUw4o((ZN8x1q$6e2gXVlPX+uVXYyZd%EH<1r0Y0()=2rHvYe#BSZxqpC;xPUq z{_`bt&|rO{I-}6XPiaw%DL55_g2$8;x$EJQ$PgMM%z@l>V`)7CC7%>9gGJQ@!6a*` znK^&Y+k%;0{zh|tu`0NBbZ6ISpA+6`A7}++aj)XNTmKO~wHrS1*e|{;E2&f_PjB*S z?MvQ-^gf)Qev|YKu7+m`*4}QOji3|-y5fN+xe%FYwI7Jh7FJaC+Al*Y-7rCa)Tk4; zet6+T1NxC*@w=+kl@W3*?+B$OP?50?+FR&#mY|e8-YC)E&>+0)$4knk2}+Cc^~wz+ zgUpY;NO9fG+AtENf%S%yS3?<ju_2|F-eRD=m?J310t+T6kOlVUBSJRgHb%HIWxcWi zd5wChDPB|dVy2VmZnZnJCN*@Y(ZEPq3&Fu4H6}%FFxuO}T|uu<KcSPe56%~+J`q`P z)@Oz+dOwtp8uEgppPK=uGi*qu2kE63l<}e{{)Wc1162f1xcX&?OV|R_dw&w)qrHKH z_Gq(svueJml{n`((Zk#cO$VBAvgc+mz~}?az7r*=8Pl{1g=pqpNmBRnhF3SgU_Uzb z4x|s4ayN@`M*bSN;e1#BO~co^PVo$v6o%;}s8>wDiSM*mleDhKxmd#jW}{#6*kxgl zbU7hlSnwM8{hO%~6&?Qh#CbDABK~=-3_b#p*6Zf`+PZ5EU!KSNf=X8^WSd|+Y6a-G z4(spi*4%s5nV=A9P}=Qam}ks2i)OC;tD=_iM{r^k#b*%0w(yk4Bu6E|711{N-(YBy zRNK>%;Tw($S4ov%NeQS*W_w40nDgpKBgiaN9ldGZvJ6pMKxL>JKaC%i+#HcC6r^u> zPw(;uV$r4bPVaH5@8HKN0KO%+L$dJNiax9AJNf+66tP$^;5y_##Jr@}3s5w{8Hh`; z`ZC=uh%ZKC0<83xj91m=63#Dqvy7t4M4SwblB}4{5UK40hAB`xdrb0RO!5YI^J--( zwdOpB-3{R6f!~tbFUjKCM*uEw`qsZa|5>j5Dq0|)E~By!a&7@uo)#lmwGgVyMG`JN z<tWKzvO^T#0aMTcC~ZG3RAMViRmjPq1pD+AJg&j-o=l1^yLk__JmZ3!*ye6c4|5hn zLoeU9OTI9PxLUPB&fy9ES8yo#@3Z~*)}UFAtZ3NHiRm;hI3w_aT&-h-VG0&2P7g%> zIyR<XG{bb0wmUO<{MWKOY?Gt5tP`{t9<tufEn2WI^LSiWHr*OvJlV94-z==b4WW%{ zcL%QEz{xrI>iz?`f-9s-K9x@{v`1$Ymc}lC_JI;9Qt2*Or!I)@wls_yw0~2GsV@5b zbUf(K`jL!2tsmc=`{}{r{D@9uZKCr;{~Yd<X(-M|sP*)NK8Y(=;N1cToxk=9Jo`&} z@nJ5rSlD+$-jTv6_@;7kvr@pUKC#c?eY9{g)Oy{a!eK#!FH>WkuRQUd3Qm_Z)Cg_+ zNbPa=V+XgbtDcdqjAWZ;C;BnT&&__1wTat}@{X9zkUjK*^iTCt;@>E_1SimR(K7pv zejq>eMe$~k35tn;Do9%oGbvyVr=U*t3yU!uZiZzN5B_6W$4|==mRjAf8=LrNV$Pf@ zZgG|)6?e$@=(I^p^zLGU*1&T-MWCOK`{$LWxr@@OgpD9Y`A5y#WCJa)UdsCfbhu>s zgxneln&(wy5`HAKIK<2-Y~MeYJ?+`)HaYs!j`{gxeV=fc)*c4Nl3z~bBr3VUwfH8Q zQk#60M2*k^d#i|@0NA$wP_(DhA)-uYmsc|oO(v5$$_>+2lxC0}%$O5gspgvFkM>+2 zE-U#m*~AL}=Pvi*xx)QATJsx*d6(jS8S6a*vdh@6;_-=5%NM90>X?r`)BOI;szTW0 z)=cD93360_`xr%qITsA++&;tn@Lo<ubA4FMA77LbUW?Nz3HhwGWP=xGFe?!`^l86N zYUW)R>6Q=^v|FugE&*(#k~GbJGvN8uuwsV(-8=M1P;r6q0li7QS!xVJ)J7mf^0#md z@h_iO;b3}1&^T<F#>@&rRUGhT^yvL^{m8DTL(A*hS8#Xn{xH|+=wj=}`>u9TfWGJi z^?qKvW7nOcn_m2%&sm40KKi)AXOAGZtv$mtKHHw4Ud@6or>GEtLS48)7(zqRO51Tm zI!)c~te>6*HjnBo#_cSoKqn+NFCIu}!L(J@cnbG)%qH=h{UjJ6v}6A5m~m_rCQ4wF zHyEpZ%)+J=H6#q$Ou#whtdQW+YnY%GKP<Fm_>cnG_Oy)Ph0IA5`#Do0{lzkS0sz#} zB$d$GytYmLL7U`ooxJLZN2dE2?1SLC>eoX2dBxDyFM1_hDV5EXLXH8fNii}gq3jpV zs2}x%iXn}pflB5<QDWSJL`zWn?e$ICm1CR|-}>n$haG65H*Gvvhc|Ltk1cldSiZ6Z z`OJCr?rNznH?lazu%B!W301v@NnPEis9TKf`?h*{Vfs!uvpoUA8fMddMH@MTS5cmN zCnjK%#zd5~<^)q1yKRw~{}Z?d@f0xBGG~VO{IuqhN%(YrkpAX#H^2W$hRCKc{GmRJ z5;A2jR*FS&*3gIa{%um$Zh<CR4ih-cUPfVPNopU@Phc3DJVy_@bnG){3FCbu%hR=U zH05)a_;^h5(!FYQtgG!*56Vko18iyoGc<WC2P8WwE;9|!Uf)U|&RKI@UQWCNSs>Wd zSAc%hCxwXmagB29?w7NpifUQA+c?5{Y%dLr+`67_EbWfm6E=1bdp=&#^brddQ&iV_ z3srYyA2lc~A|N`9GRj?PnpkrdQ3Q^?O*im0o~{rSld-;p)*ArKX$~n{9|fe+FXbe{ z;!#78>w^^uRPVo#XH^KGY#z*33!{J~td{M6DZ&#TY#VC@l0$sqn?DUJ+d#^qZnV;h zlP#W{Bb*6lFjN>hE=ZPsf6K}Y)aEhgd22xJ1>_7Sa*l<y+Lw~-8t%=67-}zm8yD0N zAEXfqAp!Xp!v4Yn#}Tv0pLv~VH8M#3Dg^C=Y-8oa`qP-@X#`Wh_!u+xK5O?ojwvLw zGQOlj!6SbG=KFK<cWQA#h!{)s{P(p3{4^%hT>K`|BylY{!q|-Ss1@O0lNiCma`4|2 zM!~k%$f=L_qCwV$Ne)>h%sO<0=YlBGlRl7q9idi7D^$=F>+NFglVA?~xg?BDsK=EP zvu1geZ<>&iaw*Fcic>=<A8X(kkr#YJy&m6%@ZVlc<yqHFA(Zk<EtKtbP6=Yrg|B~f zlBK5z!<`uufR@`DWt*fn37_zNt<_#5gwLMR`8G5xEJE!KuHU!smOr+;7NkmG8O<hY z%$rK_HO!*T&FgDJicQd8o0Ilu0cdMRSsdQZGE0ch0upUHYlUe9OVCHC{CZQuOIr|7 zuoRmv>|GpRc6o^~pP6S(`H>d0u8~C2<yUT0HzNdpsbNiBoz6{G=qij0!q*xwdoAza z>sg?j8{;9mWs3C5910Srs1W#F&PINrJRw%e(L8H{w^i>(#`#&3$0&7#h3B(l;pfbR z2&Zu}&o%g4TF$IhufDR**XU&xUPC&;02;&I2N+p7MxR<Z?W{8xm*T4x08Gt&*~sK< zKcWDjU-v<Xr2u5$kGAg$);@yz9anEy>ZPwBod+l0(%JV@u2c~A+_RU>y(d)ZaJ~E} zLZ@;xGdT4{qGqB4&L&MVxky(jj9N@TM+xO(1ho?=oxHA80#Mu=gxU^wTcd9~(N29i zn&b4g4*}tGW!00e#tz={TA{xvb`6%!L>}%Jt2QR#PKHGp?m5w7T{0HzZxv?p->#8N z*p`Cw-A#-^)~?Kr+W$qaLNA~f;Lr}Z@tlp2`3hKOYde}oY<;M-b3eMFu3RM5<#uAP z^^G+4-j{hA0nRj^2K<K)q{!r(q)21NDTXl$#`Ir4bq&JH`su&yN7ZB~fnp9Y&8a>H zapHytnD@eg#k?ngF~8^hp*<TR-2tHd;jt@iQF9s4#+7~NFIwwnTN~@F%dNKypwRYi z4efLyo|l%#`*X#=-f3ncgekM7Np{76N^DGtcq35+q9h_QTyV)*f)`_N`u+5mgb<`4 zh{qkwA^%KU{d?MVPq+D5xEYz`Y5rQ-+FKh99e(#E;*scmf|UK-7TkF_M);O}fSo0` zVG}r4`#t*CrE5*i0N6tJO0bw>wqwWTVY-u*wOvwH9$;hZl|j%Q?wDP|gv2NDgDBQw z5r5WboM@hSeh6Y--mT4&l2VuZB*%ID>SA>&wPd5S^wBQN@^Zz}<e(SOHT+_Jbq2h& z;GE5n`!AYa)+z=cNtK{<ym@0NkrG3z6(MMt02?b~l!)(tp=xIUVQ4MyRupGAZmj=j zm2#h4BNe_jHZpRbFLh~G=JYxOtS%O__n^-nw9m>c`0q<CQZd3?1{#2`?td-s=kD5p z?mhtn<-u{KR!I;_iIHuzaRS`Lxbe=akXarI4N6W6Y@87+aiJvMZ?#p%9(>774Z7UN zS2G9esaiUoZ6_|$maROhu5BStHufC$0u)A`&9naV%0kVn<#FWdHQcqakA0@CluS{` z@wphqegi}td&R`jo_$2DE`eVaJxb-0`DbNgNS;;}YQCbU2hfz-!LH+^l-PDp5?XiL z3qTqMoY7ep>~@@}>2l-EgN5DyrAM~|)I|lu;7Y^vTBf6mq~3-jvx#+PgUFx;nBYQ$ zn8bA1;zb<OLXiz3{%FcFsK0G=ZL=(((B<}Qo}b&UjK3(@zb6bdhk9Dsh=u5VV|~je ziU=$45e4Ute;rVv+G1BRkPtz(?^J-QERP~G76<HWaFS7P*$^fAegp?8nk%)z-Uo(& zIWgHh_0Lv9wu)oUTJz0B@U$)gbQS^)E;m@oi+5#rg15kC4gorr!uPRFs=fj^YQbXp zKM3GS@ZRr)cg+AG>hC^`(ab%uTQ6T|Ai3nMFe&V~AVipcH|<|KG3=jKK>p)&-Zn8J zPu@csc_dxwQyr<lmmIF5bl7Z0ZrB84!tKCbn`4YQV~y1F<YZtq6TA)o;lp|DTF09N zi4et<aoR%Pw706!5V@h=6t99wyQ0Y6EvZJBVO75rk1?!NPT6rY<o5n4_Fsz4%8>rr z&g5GoZpP>HC8r55-OBh0L$JzZL40RX`vhtGXIe8dQw_=fDhhl5c2p!OqL9rZ<U4gz zG`d>`kG$H6`3Kk-VhTq&;VF1lX%!&!MC|*Lky(QOc+tD3O9y+=4%mzE``C4^&#fIA z>b?Q^<$oIMq*9w2y!5Y&8&*Z8XzHkD7!q$2&Dw`1s0qM15suCSDcO(ktg?;M5>H~# zh}dKsvfY494gN1ve|v5b*i@$H^Lg~gYuq>*L-)YT>nNI^)4E{Q#yUxwWY^*Uvm@zq z;~SQ%t5RL13k~f0ffQ1adHrC<B?zi|D_N*bQ>x^7geC{9!p4{>asI?V<lph{1RUz6 zzXEtICge|d4zgV8?ki?)K0O^#%7bn|zyOYJlgGcE7)@i!A}q2^JOw4>aVE5!1m#Mp zFXh2ocu;myt|(Fz-lC+~CgW<N;(y#8?I}E1)6K{%(9e&-%~*rOe7*&|!<BYv1-l;H z&jYg@zMq@@f4FZmxPB8UB9A&%!y>uVHr~in;mS8f%MBpVU)sa3>0qD;8^`;uKw*gq z_a_oD{63`*R|GMOOOk{sK;9;sgb>2p0hFIJE{5;>0sR9FFaIqPnZ@KHNm&pOsg9Pq z2h%QK_-~D0L1qYr;Zlo}n5FWD%fGP!uF*gd`jK3=1B|^-8}*@Q4`eKzF{wQX^Wmvw zN&(w8%quogR&}!00QM45n1cB^?YRZdR#y{8T<a@Q)Ox+TZle?S!JGG1VKmfaYhjEA zC58%7Ce`aqIav$>$)w6cLH#!66*GH?MpQytf#ZuB?z<7hkgks>v&zY9X@@7FG8Is) zx}+GmYcX{+2EG#tyNwSA-%GYWo@$o8KC)Xp*JOl`0OX^BZ`J$!x>fJ=>sCDp2I_0K zm;T9S{j_?T$8cn?N*{sb)=Kk}WFxf)_&^q%czWU3wjW;Zt~^Dxiw5zZg)6X?HY0EZ zA?^^JKT;b>h)2tHU{B)q<mh&M$eDrUwhPc36$@`?=%oD0P=v)%<X~j{tTSaEmym9^ z=eB)!D7m{ll{68Kc8|xEbFq6_S6!}B6~E(zI|Wptf*}q67sVab=#0^CVrs%=)ZZG& zq;pZIDK+e+ClHZMfgtx^unZ~K(Y&z3GoEDdf6ek1Oo<x9Tkm}T{BtDsxA{qv3$JN7 zG~d7DOhik@yj{x|IfKkUul!hZUv+hIY<POATfT}kR&CAv)(&Puzt*~smM3v`L1-dH zaAHnq;@w}|cueSbkZpw5l1Pdp%B#IbiKz>F_e>0!J+AhxT3QgUTYOxB1Iv#{22v0^ z1;jsE&9z9Eg2_k8xl}vDI<LMwa|KcBk0ky8K@f8Xx0T1!RCDmrDm9~ks-b11fNFyb z5`a3hJ5__FK}Ue^nWYtiB+qvw6gR5+a3B<?K##1!o-*iD0URu;2p3hTGPI;Lwfr0B zZFXb#m%$7hxnaZGrjGjk<Li|LqBFUb<uME>@eDjB->QkyiqoXW$1Pacz|`Vwvs3IZ z8(PfRAl!y8Dz-7c%~ab0H*2sg(AT%VGBRiikLF*pl9JLl9@w1E(l0tctCz2~_rhrk zUwo%N{s5?BtofQb6jyabwZ23X1KNvz+Bk9ezbHO2N+G#<=3QfVlu<z!lUv9@W>J=1 zNd^;BRU<|78LvW|@&_<8LQUdcr2kW{KbZ3*@i&gI`oGl1{}_r_HbEY7=*=><vv|lf z1>XjGw`MhgRmx#)iJm8$E2(xlegjH1_R<WD%j-@Ik1v@(sGP=b8#&KdWJpPi%y4+2 zY?{QLb(NiXf79*RRFzMox_XQU@>`DzHWV-Jb=2*cKp?Im;c`jHJ6^KeM>SZwHn*?$ zAe6Dnq7^deS_N3I0K7-JK2$LvTraWNcOuwBL^S&?eEKhJVXVcpaoRt|y-LwGB0KfN zCHNA=D9DTEqtH*z7`HL#>-SkuA$L%11NNM2c}?;ehCM_pBy#+B&v>wc?4tA3VB+8~ zj22MDe>2_|t@NPqs_jWf7QR=Y@cJVoyQjCYZUeYQ+8FJLNIi%|Uwl4Ie!uy;fwxs- zQJtGgB|T(h&6poMc#i($;&f_y<if}YCJw=j&*SveVY?rsc=>%}oyNYbpi+VVOI=s} z$-(n~(vR;8XRdJE1ZMBvUg#QopKvkTL(k->w%jz9C?hVnvh~>LG}Zi12`~JIffX{1 zq8V}G*(eA)(LY3+TOxj{zekL>k2qu-p(<^sureTOI%+!&;g<kXwF~ZKU34HtiMtag zEo%Qv8Rau=vw?Kua(i?wvD`Mxd#ywa)jDe(_Xfz|wUwsW7ye<1IdPRww*GrG?hg(n zV;~XwsML`rur1Q^h}I;~!HL&o%V#Ed96+)gBR#IMYEXtJlh=$H{>DYdfMu8SEpAPf z#OX}zy_M7j<7`GVhi+wLA0NeS(w@Pu4;X!|K?z3EN}~!?2jq5Y3ac?;vqH8sd4V9+ zfvArxY?LOI6D03n&02l$f_?gm9z!oc?ENNy=GBZytLz}*KAN4;q~B_OcesyjBhs2^ z3if{FGw;c5oWhNR-u)&QPL~E0oLA?@$NMW2b)cs6GO{mq6!^D#Cpq9slq`3O&J>q7 zRGblB+V-de#<VW8<``sQP-HNLYUh!PwWnKZ&^4tE6_5G6;z|kgc-un{$>X_)-pTh* zt$!o83O|XRQrRyfmO+lFEF!q7LmhM}2NP2*1A9PQ-5k-uAI&us&WwBx4T#Rlq0a;} zxS#6JF@1OF3*+7}%D=Ml?)2n<h-T8Gl{@GqSD!#(qtM(uKYtlvtA|A$+x*o@W}t_Q z+$_Pv!GQ0KZSDujZ?rrRjz0fbY3g!!<YL09Yswe|REG(~;o^~5tK-7VW;(^ZR*5() z@R1rb5<}C9=@6F1!1|SEIUFM($13xDWfu4zw1@JI{d_6VW8`-JG05aoKX`|&0%foF z&A9W794M($4{(S$4&4~?re|zirvc#^HB8%<)#DNHJ>k|C&s=8tb3$d1dTzgJdVy2= z2ENUkq+Ns3$X_2+)^D`cdfk91?Tzc`ad@~60r_kKDfAr2T?KfGpCHxDdn{+810~Cd zhg1f&(*y?38K-+Ry+DM|eGKD=21o_CFFH+T9~L}rORSJ0WIYEbNd-3i?}pxglA(Ul zag^)|Sr{l3mP!94pJDwCjXU^f!8byl2D<$Vo6Htowj&643y38;pT?P!F29EFyUkXd zM)rZ0JX!6XjsTdX%lDs>%CBIN`ChPKMLx6h$cTJaF@Vw@j(uV0VG&%L@p$#IeDcuC zqb)G<8SfscfRWo(Q{*$053P$!qiP=!<&fSV2>wIzT@Ix1W)^LUgzWI9iY-tOutY|b zW+KzCX6O)1F%3J4pfRp0PdEHtsVHvoJKJw+t7=0A=ZK4>`b}>54T18U&*$$suI8>9 zCk`({AL@{RQwBd%QUH6cLC6reNbTkQha&a!62M!oqn?QiI#H2Dlx)qgcR`NCk4V^| zKy|S1rPyY(0^CV&+!rGnamwi5;sdS7Uqx#3GOv48%X(y6^Wv;!0NT$PUBQKhIVSYN z!5Wd~KTP?uHkLX{OleqZD~)wa-0n4Sy@Tt}AFjiG7vgx(LA-uk7{-E=IsLuyQybAw z<$(e7S2`NO&sTdp!|BrEamJE=#8uMf+2Ut;IcjOLr!^BXmAv|Yq@z-2oT&(p$Z&Qg zXVi=qLX&ah0zrhHnh|SwRVPYRXhFG4QJH3_!mBgazZ|~<`*$`9XtV60)NN8JUW`4M zMmx!qTpqjRhpk-ntpYZX&TC2EJQn?XHLfUwOjtOmAh0@;R)HReK_)_2kK#j#DkLEj znhP6^ryzt&h8110SMn$CW`6zKP(Hre3E8y~mB4QP53;HL%HTv5T>gV|ZmQJaK*N7d zfIrx9Q`U^}xnTsAcPsS10@_BW<i=yLv@2y%D{&4~2Q@&pAcqA7RCQVX*L0{)(-F)> z%z_IV*5~tY2?=JW9wsu%dYiVov|PflW)JjcWS0E*O&6)SAuQRyR)AdqES&$T!G67C zSSDItcvHvv>QeV*Dg=d?8~9enpqOD3lep4^IU{sHn?VFNAV@PBQ~s%q{ZO7pg5|5t z<BG+lQ|G4DPF?AiMbv0~(Vq@vr&1gHf9z#_Trg}$jC7o!3(^wyMrEY;n*p)hemP3A za|z=OpN(-CYt{*$ca7MCyp=%FpM|_zu4T`Y`)>N|pRE~i8zoHp-<_nmb7@<#DW{V@ zwWJ+4RpJAoYCHeM!uOfqDp{}vJx;X6%Hpe2pow$sLFSTpq>~yYCGafF#^H{_d0+6Q zUj1oe8~#(woP%W*3wO`*mNVD#bsdj{`S&_MOX3;)S(zXIhsN>z-Shqzb_pUorfd=d zQn;iTm=B3z&IFZRnPIwWVhQ3zMjDnpknKE*5kHI5KuZ&<r(mF24s6z`jYTV8OsrcS zQn;(e1YPUa+|m#|**FHaG2?kHch^4}`N1Y;N{yOY$uVYY9d9JebR;KgZon|>6(k)A zWZRrbp5uwsY^4Z*OJJAwzxI*%_db3<t^j&goUeTp>324N#Bj3){WP*yYc^u=-{diH z<mtc3<bfZ=v1yhQY$!~s?9AHr>wEPAcC`u_HHIz6QD3-s#gIwdLg;Dho~ZsEdDg)4 z*k1naMWtKwW6}NE$DB)aw<iP5+}_us>0{2k`e%is97s8N-HX5OMQnnfOcI5ON+1+M zz|`JVZIYrWs(Hf2{iEKSU#$Q+dFtt0JRyVo-=n}KWq)tYMZsnw?;)QRST}o8Ro>UG z0sehTt(X)enHVt|bto^!@2rg4qj5yaa@_(E6fTv*Uza)IH}%8F0?4G%HUB6vX5mji z-B_0I2?^&hbmr!7<C-ydwkM`9;%mUYlL+%WO_htZXSLiN|F((ob7Ji{iDbeZUyk_H z1*>Dy4c~tNByh!Bok&T<#wY}*l3@(cQPU&*39ib&_i!D27RSdx#udkA8|xDX#q6cW zs6V65V*I|bo@Ep1zljTj*uyzP3xXm<FW;$0H$(`UZ8*w^8ow8dh-x}ca~G3PGlPm8 zG|SdM`S+^ZVa+DWx)R0fcfB2Q#fEfsfi^7Z0<bWc=;s{g?B4hIkv568vLLQorZTN< z+3?Zv${#@eeWCNa=B(-i-LQCzfaT)0+Si7~XySp%8+DU>yMs+J^B1Yyk@+%p$L!dt z+L>SIzO?34EV_Oen;HEJny_##>OurzNQ@+LZIM!+3F70gxe|k1bow#l>r^Y_$<MB{ zri1M;YDi^rDzd=bLxds5Y+i`RmmIhCfdX(_8LQgB$^iNLlQIDJ-<1I+#=n#SqH?e@ z;6eHSQ3l9&jV1n81{N~^C<Ctn0r3A@8Su9dN05J=Vqoz_zJE^KwJEuYKbK42I8DmO z_`>7ZjMD42@y@>O`r&?VJl7Nu`Qh_;t-f3`w=pE*5&ObwUK};K$h#d4IUBr4A2<~& z4Ya*3f3su8e4{JFd=WD(9A@}@C!c&)-=|mqzc|0mCnt&QrWOi!LJmAZhg4B|QzSw( zYeEpDi+Tc?^rQchE>5T>*|MZOeG7ZE`oTMYY6mb@UA{c?aXx$6(!#?4;x=5bt%7rx z-IzeDtpYTCzqK>)a0~R*@VISk^S<vUXkNy?-}f{y7cA$Rc7zTqY2d2sW_v9@6!lM= zD7`_I6sF0w4-u&(Ze>a~CWg-g4{gtA8k6&CvVBBhH}}PagnVKR#ya1!AJN#1tr%-= z!+UWD1vxwg#6YD~7<bakn-_{Q>yS6K5AF_mvt~O;_KGDLc=%bZyydNe;5_bzV$WyA z_QrS>D*u+$BP#_0Pr)i8PzhXo{SPS>v52BdEC#x)MVTo;n22<ej}p~itlvmNx}3#z z_#J~fwX{?}3*<Y4VXh}Z@d>z}3Hz^8KUUMZ6j=AIQft55xhw(xXOywWsx@%RGFfe( z1kpUjd2pZ2?-2DXP=On!^?O;wlZmcRNwBR*?bIb@lBlYfQ8*_<g_VXGXI6p|1O+xb za!@+We*I`y4FL9Kq_Eg}9t(%!iwPR4UI~GIH8_Wlp&<D?a!AZN#c<+vzYInBEKoeb zTYph4R|4`jy~=cn`Qa&-J!4*_YU#cg|3!{K65_%QDP;N@+)N#Bnnpjtp|yGQ(Hw~1 z>kRne)*|C(hwT}Gt3r!$Ze6fVFX42*pNQq=w0@)KyynQ}Id3WQI-&j>lX06JsA|-6 zPs-%-R-0z`L(3PgmT!xmZB3)5Y0~Oc?*-<)DbBs>xY}#_)i7zBJfEpdD<y=l`eS<` zmJKgBwJgaO1ch#;z6N^KP3Bfr9P$+?f6}QVx)$HB=y3#mCSF*y?a9NVBNP&A6w7L; zJeh;|ya;+mw>X>RjIog!W{z9F;VaV=oys5BdB`e*b-+5eQ<$jOXdP#gF+GBv$48}} z3Y2-g!+Ye)TkG$t$r}JJ$iNs`$%Z0MTd}ofj3~NQw6MCP;>ft|>Z3PlpXxqib`A;c z<haX_9bvBnftDXlR8`A>O?p%+&ToTE6X$>z$ATuK#asivg3NRhBl_9>pd>b1Qsl{L zO{Of-aQjK{Ln*BQbI*i6WX)XbvZeWbycS<V&|J#_a!x|_Zbx+3;z8a<qb*#2i|Sjv z>W|z5LX9P=g<?kMZ+5A>h9NkBKxK=4+AQ^T<m81~)ouKQZ|3mkQ)bEfqA_*_<toF_ zTMsK$78iBI`7Y04rp!yeLI=XY1~%4+_1Kgqc4Q8|$y^Hf{xPYsO}r=c@v<zZec;mC zs_wM3iQaAZ``XC}(5H?6LCTH3V;)#@2Y&put)uEZxBf$S)~=`Om$DKMCw@;m%9hxY z__Cd?L_*5%yPjUx8^!TM_p;`fSNEyA7Ka7OG!>8a2Lp_#p~r|iIAb-sZ_Wj)D#^K) zr)TyBVl+4{ww-ocP9y^!uB5fCv|L-1dCBTOH5mS=X4yUjtlY7!o_v_Pw0B;ifNi^F z>naPZ5p0|N!P?K-zFbNW@&Fn2u-PMHqt-mD>AJkztT9*QdUH44IyoN*6W-|nL2nZk z-g(k$wVS(|y5HUVwo2g(8f4o$3tSGe8rqi`Mki!d=)Mu&9a~%u1L8HRk=Dm)dDqwp zbTG`opuFT#iw+9iomBdX9GKl`=bD28EMCanYpC7%5C|XEzi@(5wV=sm(^ULUZkR+2 zzW)~6HEW2lDnSA5U8<LyKz~Xe9fU`g_Z;<u#oJ3*&=Xbgpsa}lEb=;ZEX$!i!hHpG zJ(%-Ji(Kszes^!-;=L{v7MTH=vg0ac=npl%%4>IcEHbhJ`_+alp8MDJo|bL-GMPw$ zgj{8>YWeT(NJ0t2M@H%>CISOFQ{Xr|B{t&o1m%*ck)Y=si|mo*B9mq3kP8f0xRe~a zulCb(psNhzgjAVP%;or2L!}AV!}wk4XN8{H#G3!7#^eGgIoAIr{G^*B<Lw5QACYFj zwjk#)1SCqBsv=-7C;eR&p2sE`v`{rN>Yrzr3`g)A9jLvPE{XFD=?`><G<BR_jh8PA zHN=tvd6gB*mSpakz5y69%Y~fk+Ya+x+m_Xp496Shi`;8ZjvOn-MyoD120AA4K+)U> zR_JC;%&<B5NNs1XXS$9W8%v-?`L@<n%~>;BOa9MOOi8rdYmOvJv)>};>CHFiLgq@B z4g@e>*S(mjo4XOLYMTn2acHt7E~(M^%IoP?hmA;UUOXh@a&>Q@y7(S8*x_}PWa6f4 zY($-c*M0Sy)g$7OALJqE5kkAwmy033w%Feb!j&wM2uHQZ*y&UN9Pmc(Yh2xI%efq5 z$b;@R4{w-|g+p6hZJb9VfjJk&GA57LYC2hVKyMinldTq*T{js19)k8GeZ;1<JTCO{ zPGdi&ruv$S3rnN>#qRNoZ8iSwF`z}pee(lO93It!TwEu&%AO5=-lRu^y+?k$vxbG0 zSexdoZCN10b|ZkpQ$DTsN2vNz`BFt_Nu5TIK_rrl^J6Bi&CW4bsSMxpP3yh2fs>WP zpl7p0X%iR6c4^%-%>DVD^%c?iY4$eyv<Y8^bIzW%+EQ5Q2$SXs)MSWAy<<za^`RS0 z$l;>#-D9JzcPUW!%2bc??y?Gagr34uUn>WZ9xviH6KP4T^Tm`hE|ebOCM!%Zz5QiI zyx^T}#3)YOh-j5br%7QdrI~!N?{3q@@IBAl)Pf$>3kc0P&EykeSGN2j>RSuYz;N+I z`e7;vgKMAXyygv-`9YW(`DC!?*qLu-(3HafJ|N^<MZ?^ALm(;A*<w9D?>-5Bn-DW! zX}<r#6jDBKit+@zDC#$v3dOd$tr2G~j~R{W68^Tk>)uix!m)&jw`l;PvQ{Ee$%Gac z*(N~b4h}C9eOmXZ_x$<s4(FcWH><q4bD5)@0YsFXh3?q^4r10fy>B&e6H+Q6cJ(35 zJ#$<)qaO;aN0$+8DXl%1WqUs0GZm{i7`4y0Rb5@59qrU@5l&xfTa9Jp7CkOFW1{t7 z#Onye&7wJ{yrwUa0nHaWg|v&rK-R@15m_pnB%nMKt>d_m3^2P@e79bZd$*>;%X4#x zAaleGaW||BRuY$SR-B=)fX_Wm{(Kbt<_xWE*q>SAAH^2OON+AsvNNF$+@4NYCV}0G z(WN!AiO-{eC&4$Q7{tkD?m0yG^<98A<{3Ob>?Q?)TKD6r=Z{ro^RurLyu0V~={4;T zirk`WWnxFP*Ty%?2xiP6Y`IyxTBf<nrs4QqFm~pL2=8B-CwcPIpm@tm3k~-(zt=i? z`C29~_(J@8#j&3TCCjXGD#*4oVC1cLq}xOfWRzU(XD6;P-{eRj^eg3P=qPA#`-vFD z-(av1`BtPsL5XO?zgLj*Q=@+2y&Vrf?C&gW8-zjBHH0iGuxsf!{%X^ES1bzr1yRtO zhRp)jXUkJS+kWpD-5u8!yW{0+4qum1f`kjE$4{X3xoyuiu2Ib|h8%R74b01(cAP|N zhhN&H@j=Y(S90$|ytJ3ec;7E0zG7=+X5*WjN{4T|VLs}^_L(Y~blx4Yy{R|Mi3ezM zv|^Xps4^2aX|0UPn__{RI;!FSBJ{H}K6WRwbeIe5N5v2x`-A~@SF`Wgq!LCMEf}K6 z$t9#x@VG#!7krYFqVChr=b+}kEzCeYx-C16VxNUf=&j(sDVnkuS)0Mwhc01OFWBg$ z%X@(kdvkJnvm}P?X#5KPTc(yni@K?d?yGwwm`$<!87H@!`@qQk{bXhs&qch!c<;|| z`UIKS>ed;U7G#P8%2d)!?CZf#W^?|HQj~9y54N#ZSe!*_j3hPlDo$|q9#s~n3i6pf zsu-E~x34S{J|YBYV`)Fa)<1z^Xj(t|AAMvR5gIMO+sJfG{gAz?DZeSy&C}?FuuAPb z^^tj_YNOk__Vau+ETzOk`*D3&&|Tm_$`JK@lCv7osb6jFEK^j)c3-Y19MxlhWO|81 zIigrTO&TAVlv;#AOkvfiY&9KE8c(Yrjac0e?k7{SwOV0GglJT?CYz4o&50AJ68q2N z{>UU2fPLO5WD>f3!2ZU`cHD`G**`q*PgeP#=l%OH54647jBtFX7c!TNFpAvkR%@K0 zU%fK-e-mg`h-i>Gqk+!;WrGX@sJ`tOX_M4Cf&bMy5n^%j?r6%D;k2DWcIW9K|9Q#7 z0DmOSDlJ^hkQ7-tXWfE<kA|usQ7q0j<wTiRHO7N({7B$u`~x6gzM}6K79e$bCO$E2 z_Pq6ipjAukqk$Di_Q-i67FYd;>KhXcO+#U+XC@20^q@#Dyjdijlo8zb`!6|C8&W>( zxTtfihQDbOlK=HMzi1KBf;X2zeHC@A`#=&ExT%cu9FB1lg|c^@vXaku*KepxQHJs< zod-Af|5k;$f4+lTkE)c5?a$Dtc0sT~Sh<yuB<j*4gFLFh7=PNgMzPeAYT`j+v5qYA zSuy%oF`T~!cVj<Q7Chy^tjm6Yy7c(6w9TXHvQgk6Ac3z%b<qjjwlWA9C3s+)4`y2M zzOsOxDQyY)m-`G@F4;VkGJc`aRJ}0q27}|sMlWUH-_|g(i9)xTKsdnd1HX5l5Ha;E zfv^6FFq9{u#YLCf>M(ZyOS-_mcHKhHV$O8Q`GdHSjdn1u{62x`ey`S~%p?wbRVHA* z5K#DJwi5SWG||*liE^1RnLJO?+M+sVff~{>?qh~BBdKneSD*PJ9pVuEf>e;<hovJ8 z&YxlCa{F@ooA36|S^%{<?k{TpA6;J^S7r0<OLqzgNQ2TPC|!br(xr3@NNy0=<fgkp zx)A~CZrC&kNK412d()kFZ+w5}yyx6|KkE+$m{>Dwo_W^yJ8R|<_OL&+Jo(&^3rwk4 z7B8cl6MZ$O;(%UD<RP6AW3xH})Aj!f^eW7T`X0~g*u-hDyOybP2QuRmN@8eJK&yT{ zx9>xZ!C{r=VYFZ+no@Zm2ta!KNJyHf{}aB_Co?s}3B7<t&hNDDkNCc<Ay<Y{>q7rf z{fR7UByfEC2T!I}ss<LVlQo<DFZt+HtHwTRO)`(!aHdFZ(mXkfTIieCk4e?wph{T! zUD+3fNz1>Q1LGOelt3j_Aywf5PLbXgWt^51+e9i`$q;SEX0cSU-|hUpO7YyYr$K*T z1@qI)vI-v(QH(5Lp?y{2csL2q9?$xHgF(Yv+bJ(#=TH1_*v>7h#sA#7{kaC;xt)y} z0DP$ql}Be386*g2`~J;b>)8R-RH=tm<;ldX{f$`KS81*A9@<`LM>`ZL+o|vr2SWxO z9Z_l-ewxqj-=dCz9%oyYY$AyP5S3G*fJI;VA0~Xk8ZEJl%WYc*DEd#034i*8$@Z2& zNQM{>CXq8Fkx@DWa;AF1{J19fdtVIv^hdwEzIzy6Oy*`Pm2$r?ns+NRv)sr<EJ=tf z{DVVxlOa)CTG04nj)x9W-uf5cYY6D`q(7ORWUZ~DEyULMxm>MjI^=3sQ}($}M^-7$ zaR;^MZ1}K<>Ev#+gWE_FJ=c%P=+W_u@FP)HkUYq;{ZG|uP-i?>z{-=RaKh~BW4%`k z<4Q#}2Xk`+S7MM1c7~WluWr>hbnGN3;5&*&<jeD=IX?hCSoCi~9GcEhSf7s^PWkQ! z*Uw!O8>G+|*vx0a#?&Ogh-OVxt(30tCRAPDUrJkS3v6J6+|fPU525XK4?A0^R;5Bv zHc`j@fRkiI?BTrA3rVG^u-g5uqJtL3BWusrd5fBkpL%3`+y}r|QYU?-)r)anX0(YH z_G`jH@qrZ3c`BO8D)i?b##Oxi1hOW5$l)QYJm_=T6sX`;TBrk!&Nm7dz7K?c=BSiE z)P+ar{7=NFkeC!{-kbtP=nSDmBXo-+R1!f5R7j2(BXoZg!7M`lujcJ~+XV>D4F;(! zdUe;WEMt6_4lvnz?ba*{UeNa#O@1?<b->B?{o293MUW-sjaRW{m8raKr+I@iDW>IL zqm)**@I~HqWZ0vXxkIsFJBMEMj0$~H>}s(_DR%j=1(vy4{$oZ_>&hT4tc-dSzazRb zRH6oX0#9A|Z^CwVmg(0UZ6n{GR{l(c6J5$X^++Fvjo7(8B|vLJvOI4Jtn`pRyy|7Q zOKT&Maz*-EPUrJPq^~7<;ezB(;BjvOu(@8pNkB(P)MLJwYG0h)$MOPNNR=*AL*%-D z*eX$D)$S&iHQSOc><ueJ^%Iyi>+?6Erry+6-i9$us)>;s6U(y{?C=4_iCxHem&R@W zrv$l8CQ+~9K)YDJ?z88(z0dxm^AH`pf;xi2^h^7~^tCBxBPi{0sfm`S`a5MPSg(fR zqyYHmjhCM#I-3(rmB4+3|IujHtU_2#PS1s?Ecf>eJLKO;`xYifpRjx1>8ddN$c{X) zM4x$XhLIn+5sW=0W6*@f|5xD)wU5F$@QSdrX9dL-YpmSvuceJZ5BfIqrM3f7k1cne z`~)iMW8T7wl$XM?0=Dq}tFM0(3&azO+*4=en}YLFznOQDzA^?M*W|~N><91CMg&*f zXIaI?bY=Iu*<@Jp&Y5>Q{FkvPQT}QBh$eqF`2;E(V4WczpULU#_F1Cl1~`E?m@|7M zBI(slQThLGI?}2;36t1V%tZF9Q96&31&vng$O=bZIGb<7G0T}p0A>&36HX#sI}PqV zxOpE<Wsmw#!xd%fQ083n&rPYapS9EbXLbVB=I1(hY3D$7*1Z#yl~ne}{?V-xwM_v5 zP>EW1DQAQkywAeXur%`DMBXpp^Cj8$ogg`^{@3qaIL}{Zt7GhuFJB^d&$b6LAr!|O zhoU98gq(vokX+t9{aZFCwxa_`JICQ~-P@{O%b@-_8Y-l7KWNV)B%sM<{jhkj=VyJf zP)P-Xy_CD|D>l+cTHn<A#s7abkpIAdv`na&3tNW998ipt(55xUI@R6OUHdh6&h$dw zEs>Q_ZqGGiso@^{^0h{<r1}Mn?dXSV5NIvqm1VtILgSDBW!I?H?X;;Lv2x*J0O$ha z7>h(!jsek7Lkn1k;Sc+N`usQ1WWm*k7E7>tr9Cr5J+j{jRCmpwO%dp)=fZrYmQ=6p zU;zr2uqQ?DUyXHrMF87c{X1%r9b^AA4E|I*a2#tL5_33mtq^~*(a<m<0~`1z>!6Jo zsAdNCCcJH%|99K4c=zsUl%ye>+Z)-?0ZS=JX|GDMOsqFGe<JnNME%(7`lBBoyf1p0 zEovs~yZlJ{OnT3AM5uMx#JHbua9ORUnlkzs3UXs`nB2;$@0xY~emC$u>+?)iy2Z>{ zL1sb({W=G4zWXZ~cHTEy+I;w^i<Omw+UI(CiMh{NIZ|=_>xxA)r_I})6ERQh8ow=; z{}2N%&g$%EOn<%jk<dFVD?AX(NnteXmAq@}Y<(d&n8%p2oeTZAqaV1w|3Z_x6>aQ! z#P@!E4)@T3)o{^_@I0oI?LOXJ#g;7W;$L`$H%zWV?$34~ro}4x?q~c?#ffHW#;<$% zmS|3+%4k*%9t?G|h-SPd#3Qt;#y#@{ThutVKo4K#KLq|c(mAx;>f^gTwKX8C9Zyr4 zvx3sZZLS)=0!_O;jKqt1DA05)a)-AVF}ru#od?o!?3SO0I;#cM;YjHQ8gX_iCmPic zaIcw?o%7&;$OBt(q?U?}xS?px&(6*8ZL84hXZ&;%d~7!iFGAzKW%r65DH)Bgp-&`E zvOT<HQ6fWmns{9CL5R*t|GHOCsX*MDyB#I5i?B-)Q26HpQ9_K-2DM?ZhZO0mUIbm@ zo=GA4p+Ft~WR-lgH`#8fCZRin#;bhlMMGo5pX5x`6bUAE&vg``;p17kyJNEj-FZs& zIfc!Ob==>XUMIJ%Jo;egdy#Cq*nA|fg<HX2>*Bhct-8oU=rA#OB}4;UAPn;Ql4c2x zKDT>LY0RVCn|x?{Uc;;_t;BRJQPKA&Mt@j+0Lo)Y{4|3rqFtA^D?Ci0O(jcYigxcB zZ!9HyJ3+W+HakKw#6AZnN1iVwRHRpn&sQLV2t{`P<8n8K9vMxUyD0ONS9Mz@SD<N` zw<-DnUIJ*8z3J?Gs*>7i?eKhciO$7afS2UJSlTJ3mbm)UPCWB8WyxUmP@<s`t~6#- z)#-b~CMs3m4=}&p*pp-5Qp|_yxixx}#`&qqv;~dzJuB04s~;o4Uu8xNMTSvGR-=w_ z!0NnmO}i0P!*lX16UVW@_fDwHgz63#ON&Ns?<GKZa%4@!od{}@Xcfet*Fc|CgC6&i zGsZ5srAXb>Rp#CIh8ML*gR`1Xscb&pgB^kkxI~|RcN@_kEX)Bd6TK!$0S#mRWQ;W$ zzPIV~cCZb%k4}8Ae_k>A$J~E=dn7HJ5ZPaGK~7=JnuoNab3>zaABp>R-01R|AsI|u zMh)winO4Ce_fUoK2-+l1W|YLHyR32z2L>upvAU5L0|M(AA3`z}tV9PY0JB2dV2f}1 z-I5LCmH{eF+(3F0WgavZzuzVeJ^F#u25bQSSijUQ5e|2gJ*txO7`t~1Yv@747jrzR zo~3&wC52~O%Z2L=MYir(cOIL$X|fXiAKzQ9ni!*-W%2~cLfw3px++##j5G3P&6LM6 zzsRmj9cQzweZzDo=o7|3JV!XkDMvRxKyXKJe~RZGoA`G1({$-np$3%j%;Qk?RVJX& zAVUUiWV$#LLTzY~xl|N5;CiR?#-#X+T+okgm9X>2I!-%iP0f^>QS;<8k&<sfTUVr} zThHm*S|fT=Oe3BQ+V8A^KX@idNM;CTr}!5XzQG8*2)s`Tc+q0pK9-1A_BI41Og)IK z;e7`O3uh>#{~$XwXS~)*E`e@qaYf4d;RKK~rPa~&Zt9s4_qDyE{}~pRUL{A~c+u?o z0@D4;Ybn0(mmDUp5tXt9x>li6G<)~X?&pUe2y~_uA+&3lAIy$aH+paoLVr3V6ZaFL zj;kRkOL_Zhav(4x{0u<;=~r&ELde)R9QJ!>GVUqqn8Gg!Iyq;G-l;d)-wQs~$l9=2 zAq<3vRy}i_BG{u3aghKqdaR{-KO^MECn^;kBuVTzns#J(DD2N>Nj!MH{&s_81AskA zy`=T^jbll&hj>SnEd{m}unl3gm9~e*Sgq~S?<ffM2_uR%ktQu7AAx508UA#lEv>gE z+Wx{$oKn#X5V3CRAutDa29p5nj)-!6k4o^XGM7=F>^(pNf_xoFz>)=k$-#$H+&O1N z5EmL~JW~deem>bZG4WyZ?H)A^Y1x4{xHTO-N_iUbF~h{=4dRx&od9l(p9Dk#f-M8d z?fn#YE6gn@It{Ep$u3Xl+bnEhBO<*%Beez$e`^O22`-yjjTc`JP;zlidJMW$)XLD! zI1lQ}y1%l*Q&9goH7{ATSTM={eL7P20((J)*Ndt(@31j>p3_~S3@SxHYHL|)yVD`X zSx}(N{}C#RXSHQ^c5OrLAy~|fJP0+==XgDrm@ngErIwMHH!_c3@1%AWb}h+Zsafe8 z9#up4R(4sFw8z=M2ZQ%*OB<v7W?7%ph5djSVVQlHpubrg$7c@RjF-2yOa6PJh-5Zz z$Z&m$d`7)TpJt5eTjbyodLfh!j+ii-d4B7e!Evaty|%qPv?Qrh6t1q@c{zo(`})AX zdqcidV@OhWEVHR7bwa;vE9P@Q>UGbWP)%CPU_;=Ml@MtiLk<N%T{CwT!vgSB?gw2E zZI>rYNWhc#SX}|t<@7F-ru$-^PEAprt9#Dfc_N~pI*&x#9U49<t?FG_e<`bJUV9)` zcDC6^K}ZQ%)W*+=e>g>!!nwIgJnamBZ|R=@`scKL^j(0t)_9cpp-3~Im_Q_FRNBh* z!QGDb&46eWK3PECZu>m1Pc-XcIJd4LzS_{cB^^?fCL9L(I37G1z>!QITRTu9x%c(5 ztMnoT|J}=Bx=SwXl~xIl*Oy#Nt=>KJ&f53V5e)|!j<Yq50U53B;$&IIG)wNAQ?H<F zmZqDx+BL}p1?Jy@9Phs)%MZO(A?s#?{xo5{)&*}*iVhsO&5h=Fa<%vItRb18Gd%bI z{KRx=*3XLG`9Mc0el`7UNPCl?d54$8i6`;<(D4(Mwoh%7!@Zv{<k75At@Q+~MT?4) zpO5f7<0#`b*0_I%&5}OhLG-Dk+&0<rd_|l^K~d+B>4gqj*Ybs&LiMS-r?vss<j0oN z()B3to<}9;@q4JrZ^~s}{&M7$O(I|NBNpsf=$5}DrjxR_S=&%J8qQ2$E5HdkOjw|d zp!gI+ndjAef-+2HB<0FO?b(gd?J91Ba~Xfc=Qcw;7YS=}AE#n1#y&Mho;i@z_RVS@ zILaI1k5|9Y0ETsDS{}^|n?69!jQfX;_=^&6y8{hM13IH*a*E}K2dMCiv^nsiTEz;# zM(eZUjXTS1Uv!?<s9dcYUGJQ8gG|)#J>vAK@&J+j?m3Qp@(}9yGTd>w-p<o>l`FeA zYP|6a>2=|pbDDg;{-fQd6y4d0BVB#%D`@p`?w@0O?JK{E<06*K>s-#>erGwG+<30s z1pM*-gWPxnVNWg3*SQy`;<l0T2cEhuN^AxSY^|xEz;W8t5%I0^O<t)M7l4`+-~;FQ zKE!PHZZ|$^yx;i%LgB|jxrTv`{eiL+V^vd>sXJtGdVk^Gr%~*S9v|L*;hf_g1rp1m zV5AacK1jPP?7D}+rw6=>((`WqLPXe(LjGI8B`B|6X7Q=m%SGR8sh*9PEPH+B;4}ol z0d})!@GTkuzquvg>^u;U`c4pU5nCs7VaAnea}G4P&9E3uAztl#;PuOhcMrJskyF?? z=*X3<*2QM~vdYkU>~&_Sx@L^`C7!7^1C5a%vIfQMoPByjC_|y1PfVY`dI9k}HI~rN zIm}i$<-QuL2+<oz&oMYqC6FYv1tcs3@2ftz9&0(q0Ty3lIr|Cj{21%d8#9cTL9LtI z^E^5JeS9T(pcH!wD3j?9@Q9s}lgcYMO(B=a8G8%OscfQhx6HqsGntWCjTp?7CF+UP zlE+wA+0ZIBv~7zBLo)VIx}>g!gn6?Ht==UZJyXjz@_#bJ*q7X|_8Z{^!ix>bkP5rj zqRum{EEJe@(PyLF3o@&0b*W@iyEW=FC%fs%1jtam#uiYrqh>nbW(sW8;k&w@2>T71 z*Bp9JrsUg~uGkdbEL>eZaeafqU^@C$I`@|}8BZA*Y6n6`QV@6ZYg!zbgui1556mRP zCc2x`$#6xX*b38Ugx%`=Vdlf7RwQVA^23D!{vemLb_us7X^-M(>qXOWakS*#udZBP zkpK1@Zl>3UzNCWNmrw3$KWpAbXBI_Njk{-N6!TxKd~<H;J-kZ%>XsB<6#b{Cw+rvL zm?>WT>={yfh)J(nmxw~mU{)UQSX_Tlz5n7(zwc{aN<tS?#By_QQIa<5N6DZ*H^iJi z37D?x71h-<5CzY;HEQbsCK2894Dk~VdZ=-xk*7~6-AfT+E(Tn#rx_63!36HJGRGAa zHBBIwR`-%gJbYMwqbP(u#JwxBJs<98f9pm(7w{G}2p07y0?~}->}0gM*)QszA;7bq z=<j9otn&Qu77g<e73+dIGH}5u4?;fopgY$+h#r`9s;X_OZkTCo)I;7&4ixpk+ar{H z^my3+ogWdNpHh5p|KK3qU{T{!nY@KNX}S5c^K!xR-V{`rA{jp2LDT>A7V%uUErY8o zW41>s_uA#A+*5JBBV!Q-UjOOBG{f;&ySJ#fC~_En1?<OTa*yt@3*9Y)gXxnB`M-}8 z9i8w1UlqEkdGOEef}x0e<dj1a*7$~S8AfpDHXJ53#y{elo@ul#MO#)=5wG_a9WO^8 z_7U}UfNO(z{;%AI4INy1b@}g9Si~GIksB98ZWQ3dGu)KO_Cry>Jyq9Rlwr^3KiZ3( z)1ty{<uFrt@G9p{(cYm<E2K!qTU4zay};Q7f0!3<7e0y==*It83vC*CfW-5~c^ayF z-H}>L#f9DUQY#y3TZYB-$^JSP%b?n052!i4|I-HIdE3_jgAh?U(owYv^_GQ=H+C!? zyzm06zkNiLc>iBrDC^1?j7as9tZ0Nn(SN#BD8Xj~ekQiNVJvpJd9WzK95QScRoP8D z5HF_wbTp+ILOim&D&Vpgts4wbfnst`SDVe91FSM?oHacYQr1Zhm(~<ZuhNMC2mXbV zYCt;|gD{>-gmYq3pH)IRgg>UgaCKKMBRgvdfz?<@zM$euoTu;0>Y@<O16xRuVMl!E z5Rnm((Z~=gIK2_Q5wU|12S;cSy)UP`trVg5E0SNcB*Csz%=5UE(Eq*mWF*#p)St^B z{ObeR?t8hXvvXFL(dXRhiv9tIV1Yp>SRm-KoBWB3*iXeSO#6KN%c}^1ndU`703?(} z;OPD&RTi%Y&+>6mLhUk#ElQ|p|Gg-M=6&mQi9j|ZU<qsERvfjqHndDWX@0ASW}W15 zg24O~nYqwJ<$2A}CvtBjm>9=+BOxlM*%Y2&B?h$)v%1%HFCCX5W4)qL{yZm&>2191 z^BqY75hfKQ63fg8p$xskntIoucS=+xwVgq&xXhHVnUvq7!7{w0U@k>uhPlnmPjQ*~ zI?J{BAL2^CmmA&B@SG(Cggn?A?=jFMZ+S&(u9_HJaMWs_rR2})-Q0Y~<wC~GKxG44 zZ*$Xe099sHSd^e6(Y<lRZcMK|y*2Vvo^EsJ{L)1D!by(*>AUBSi?K4VllWAmWn)Iu zm_n=Vr}DN~taas0QnNoPmi;KInksz-OKYS(zX&N!dV(eAjWq4gvDLy+$i?0C_>0)` zlIL`(lq|?}@<}(F-3BwXN;k@sA(a|DcC3Y>H)(9~YCd-p7%Kwrg?eaTdlHY!3$W%3 zL%B(l{TF*2UI|C&{(!Dhue*hlmX%}5xbmnZopemD`kz`BnWQe1JH|Q)ClJx6HjHAe zrl3C6aUX8%BEsT_x%#`n&@`*PDiexyJL$Tz2vYL-B6HFpf~%e19rNkGol;<amC|rs zM1{%coXOIU?o=)!UR>%Ei-*^{(mj*1^?8f?Q}vQB2PGQnqM!>U8+R6c!q%b*mpjD$ zce@$I1&16-W~F~-QM!(PtlspjzQ3A3vbiCSRc9m0YLgE-;D2TIYu%zOX`B>bsIdGs zjg<0D8{x=>H#pN9?DHs0BX!89xTh@yrySq}usz!KJ?QHjXRK4Zs>A7NEu`yv#8c>R z8OS*s99!Hkm(mL1Z&AR+{Rzkc{-=N}z^2@G>gVhOrp+v{jzO3+%tg$jK-#*Q+fE&R z1Cd`RDQ-YK75xstFh#vagGq)2L>Cc+4B|oo<&;f;KcZa&*M@A?3&s|U45%|TMukQ$ zoX7b!PDc4nvS8Wpw<%Wsqw8oL3akQsl<S@ytL;?tEPx}0IEg0>0Ggn2puG=e7`dn$ zuS@#|jzNNDqaNY!f|ZAh(suyb-%bvCQG+rET$qYWG`dR$YJoH_;br(!gTSWxLgjr_ zBNt0{uY+&kdcrh)3n7Mgm2NBbPUPD2x&%)HUv_hD?=$RhvL^ENJ@7iuVT_*?4xeBE ztR4=(9syVcd}v91!F|Iy2Sr31leLoh?=Co9s-2Nf@1Um<o7sRE^H3ys-?skMH-5O% z`WV+e267NZwClVmjk6rswZg-!{B_QXnDFtW82kVsxWI)E=6|Uu0l}FRLA=(-xt11J zx&e>$MqL}D=hSq*xLs4tIylG!<Za{tB#pY?WF53Gm(uOSZTD!s&J-pX>U#s(LN9gg zP(E3_FmbI8c;{4S`-l<OJx(KDjPQ?KM1%ibM)5=f67~jS&^#_OAOQ)g-glW2-gLG$ z*|fM3fK46Xb%uGq>&9Ave;ua}IOr7D9yT;^`L;y?OhJch`BXd#ETyPaJKy=q<~8TH zB3@1V!<+57<V5fr*r-#F{o7{K7SvnrFSl;2oKt$4kwK~05005PXR``twDp~MaJyaT z3J;z%cRWrLJZI#8w>I}UCig1~!1rj^k?}kiHMvE?4F-Y{{k;+ph$6&V>^dP1J~#fS z4fbl0Pg}@N@4B0yOE{-``eDvGtg+7M9;th7@3Ia6`2ZmrkOTVvT@LZD?efIAj(>c$ ziL_d-X^iRKIyaj@hmQ&)Y%;46xFVg3{`hapy@kLiy^#y!k&CH)r;y5G0hfAD)xrQL z``=1wo%uKJ@a7I0&Ks+Ly)~+%f}3v#ZhW|ihJcOA<gyOBvjDdzTND6Dcdz?sp*D8k z{iG!de2$4u=_fuP_J=<$)FT&~^A`~_!GE9k2N>+tjwPe#yHoB`#Jj7ksUV%uo<xBB z(Ovk%61_SrmSVMiK4DeaIKZS~*<Gh3A!_|Pw%3qdX}Z!x9@@9Ipx9AsRr4j+?irI~ zbeddy?aeH(><u(vErc1TpV-Y*zg<<d_!E>UzBRIQLpE!-!ZkuIrUmor6IqQ$z0^q8 zr-j{GXQw{{(&naN@^!r>noGPyRp)lRqTdR12x>=cVmDbcMTCy}KQ=9Ba}r;F7|1w7 zECjy2gPe_wpFJO_q%TuiT91hZkG2qQUmTilBt)8ok=*xZ6&~$rU=m-~2na?wsMUqr z^y`0?=RHfiPHfDki99t~K`0ihRcQ8we#yNo`q-#*7TMyou=uKmW?HHmyLLl*njNJk z=<{s|c1wqSw#EKxThdgi^uvuuX5ex9PtPZT(Dv8VdSlgMM}>}Kzd|jZD>Z4cY<(&@ zJfKyw%~2gn4g!9i*=_Lj(bctbwBF!;meI5DAgUwI0-&M3ZM8KAIYIZx7D7%M_3;nF z2FtIeF@%q*i{`gAHT;{p_@`9_>3`H9<1`7{{7CGCojmy9sLQUemlBk4Zo8*o*?o1% z+6<|qsZF&n8i;#mP`8)-WAdh0cVN-?2Z>coErY&O&909E3vu$=kzB_3T;%rj0QuzZ z+|zT0FrmrTH(x%z<O0hzew-2e*dq4sxr{|3w_@dt6+~dF2G~tM7${oc*7BKcFC$BI z;uuS@jI&l>^|L7J&Ai*F30uW%(}w-}!)-6f{6qp^5O7mMK}}ORJ`rU37N2_9zIhq? zvCi+|gk~FkkEZu>-Jc78!M4uR>9mI8K~lV1X?-l$wZ0x?oX8KI7%Um<8%%aMb1A>M z-m+2S4;j8ssuL};DD&89OGMCHn2bI6lNb5culBm6e9~HJaHqS(Bkc<+^pw>glG9#y z*u7`5jrNF7p<*121wAX7nd67LDy5i2WZf;;Y!nOjInCC%>AsKr{4nV{+$djlx4bZC z+1w9Q3PrcCHz=l7I10+Fc>!s*BHl+UwKk^3b`Dwi<f?9GHQ15Vm$u{bYykW(-|F9h zFEmd$WA!y&vs}uG9uMm~#)H`4W(loNf6_cs+YWRIT2G$c!Xnk$4|HF(j$@}nnX~i0 zY=NGfWfQ?JNTCIkw1zlfUcp=r`U05%eMks4egg`@*9bQwJ6x<5?C9OI@Q$DB7KO*m zuaV!*Qw8Zn^dEJiM%iL-?yg%Cz%JkY79&gStSe&_WkhYb^C9x3Gg8z{Z&JPV`yT-Z za?1+&v8Qh6){?5{$>#EX^47i<`dF_>Q$1El(D$?m_FR!*&<Sx*>(ySvq_Z1>$fPld z=}`BEp9^>7(=@jp3BAQy(rKmHipO|0kz4^tyb`zHeYT0`3fKf*$trG0WyI91-zDi> z?dIf6NDMu5zayi0m-htNd}QGrCeRac)EGOtaRw6hGIgY1&M!={`oR2_J|X}NGS*OJ zqY%8>kx)nn1fCPh3VYk=?3b%pCC*JYYS2TIp&NmbbMZyY%{1bw9P)~-cVF<GXTzNg zRZ~ydr%GMfQ_qc*rVpeQ<++Rm*GG#kst;pp+l-Qrf4z;j{WE}-qRAQK)$*2ah+jCO zhI5~oVf@e0F3(wcf9c85$9!ne#b60{v(^Edv&EP$moWL4tE=4}sU31hz$Xn#e%HAV z2bN%H72A3a)F8L>F2~sPwvgXg7j(AJxqDzeT(JNFngR@D_}AS{v{Q4Zl4Wn!p=Kp| z<9+p@E!Xq}Tem0*6@an<p+|?kS{N1IZdAdhIhS+O=&|{4@m$ewyX#(TH!@apOf1l8 zp2nI<i)y^op!9R2atQ2Jh^X(}Zt1Sk7tG1G@2y97*%x9d&I>dv>Nq)dyB|`Ructar zy{u*^PIr*z$`Xnez3vL#<bdz1E+)kgTVqQXJ~}1(E&CMet9mqypFcslGR40vDk-w( z4<ZG|dNUjsQ6#Cm^RVGrHQUaDJ-5yV7X=v$Z?W<t<>|-Y_bY$<ow4?<;76VQ!H3?; zVCv?p#TNmV6LC_r=HIC%<`u`fkKgD)0!BjtNDBpZ=Ii@`Ct4beFAM-3V-Hh!s}@Qr zQ*Kw?I>UMT4%iQ8#{@>|gRSaI3*6RolRKY0@2)OP6tujq+*<<|9rsTucs}tY>+C92 z=lxcTT(3zS#V%>^+|%ov7OQvq<}Ug|RDUQ#fmd;7OXDqJKTDw5pGoNJKF2ZHcCo8~ z?c`oFKgWE~x|W>o%|(xhVk&jAXA&u@xg0*eoU>f(loEbfUH>KGL|5+z{Nx7X!bsX& zV8K((MqSJt-F0ju52c%5+=T+M)M`D;yd66|R`L^Ai$YCCeZ$MQog^rBcch(XfMTCr z{8I7y`>>7cOvfAr*_hXEQmEdZdlc*IwlvIp6Z7w7q9&1K^5(1P?3cC9xRi=7ZigAs z9^CR99fG&U6QV8@ZRga2m!Z(4ek2s^Jyf67ClNK9LWok45*@E2cP)&NdTANURmQap zCA*QIXIXGnnJxKzRvt7#xe4+OpwV^-&PB(@f9jcSvR%S^rL}TT;fG<f?E&V#<qdAf z7aFuRualBk2>^$BD1EC6MBna_XNtI*a+mjYS_NOn*Y)-h2)iDPoOBWqTQx&0C~NL3 zr~49xI^OGL&3y4)BOyI0z-}S)mt0V<xey39JrGOwrVaC<H8F}mqh!xR#6ZHR)Wnb! zzi<LeGDW%pgh*ub^ruvoRb&Q{hKSO7`*2i^e>c?%UWs{$_K<-bH6w)v(;^-8Igo{_ zqbQCS8(PMuR+cWSwXSwY=uCw%uU7QJ3)b2&5x*jQO$wvT@TP=|A$jQ^(|Ts>R-6Gl zjxHWu&Au|-K(-lxE#HG`3pJJ4;$qQi*sR+!7<0f5bi$+S7zs^W=c%B^@F|djdps#t zfBi=ZNWrNBhBX5EBT#8|B-Eu(W8bOzIZv%0({yKfWNiYKMFx5Cg65767fuHg8gi+A z!x0YpsUF-j`v-QEqoR&nNRHGg8+?Gse)c{{Zxm~3gcQ)!W@4>8_rHUHX~3f*hl9ud z9eBhD5UI<+=#Sn2m>ggpM!aa_Z@Z9etGiU)rWj|Jo3MRVtC;a=SFvV;bpFKV(bHz% z%mPT$9inXA2!f=h`C;BO%zu7<4TLKKLjzY)-@t<ky7P0E2mS)|U4lml;8rSdy5Lf( zkZTV&ta|4MJG!?ZWDjHyE=mub!Vlpl3u{)nZqrBTdHDnLlfMgxb*YY-WwU;XAp5Ks zxz<f8R^4=WbgNj@`<u8`o#1mEoZYKf2=Wprz1epg4bXJLkFrf!uKfn8=mUt9gpWL- zRSdWDpcm=Tr9{cFi{|FUtasE?2;lRtpG~iMH(AVg`t$j=^nvzFY@w<D$uuDXD*`LB zvJZk50trHV0CN2JvE@tGKWJa**L$TqfTXcIWu&ivvLV0NTh1T6{H<ozG9;Xj9>Ee9 ztt}<(2PI#1SHDiWj-!CiEO##VCra9f==B6EYhUe$zsMZ3gr<IR7lfF9@8fv_`#y}L z;ddFvXB}H@Z~a2_B{=AK`aY?;eaV4EaUHkwqPufxAyt0aRSb6k>@O$R>Frm?Mv!84 zqMg@qx<l1r5nN>VJFx=C>7BBj5y1UO{N0&#A0=zxBvs|>1d+BR0Zs?*GYl+`-Z!(G zVzqg%4y|M9(T2Z1s1;Hz`P(f95eblkF~55gc@rV0dDqjnVYXogW6MThe;->WAVsl% z6GlL~<^g4^U>!)X_EhmtR~?sm=VnRwWW^WE&&f=V2caPSyqsDnqQSj@b|@;Ki@oBD z07irfQ#(KECEBGv#$|#y9@y2s1!!OE=yqAJB|Pt?iFA1nyv#dktNY~;1h+Z<5{2=? zf0bkJ#ye~wJLK@B^pNuekxt^#9|$F^%X~zEmts6==XWB2Pe=qXT`0*C1x(zZc$mPa zYJfLjv$x%O{A7wBYxm~Dauw*LLyhF&CG4^4x-{3vhBz>a;DNtPVf?7!qmI^oDDbuh zy(m3W|3|dZ;{=625hoaLrZL-K(72A^1osdwqKM!Iq`!SLGr)wzna!YW=RHaOl>fE1 zahdqy{O}(X?=Zq|e~eQ52I-o5lph5Kw;thm769uF3>CuA=Piso7-7Gmb*jBhT~&3o zE7zaCZ-LCfP?PO--a+Iw<9SrgKFU)H6gV`5Jsf|_!|*R0Z{b|$iARH5&ioD$ZAAAp zqC83YVc<0rd<5U%q_&?HMA>qG4<R`JFVIA{2ZO#Zw^BVgK;|JLEH9r>Tkd@vO5NP} zM1u(O5(eeCZl$g_*ufvzo1ie-N3iU^yw&v2#ywqUbZwsobWx+yLw!Fv?lQT>E5QU( z?KB1&|B@vz&kWdm+w;>r-1q;c+uc{!4-yE7hJL2rE*)NZ>X@xge0y7yyAb41*1ap| zW^$q@jOBTsn+C4Rva*+<pdk|GzKIi5x17Q*rdXanB7Ui)X2bh)`}wQfW5lcI3n*8T zA^so{W?2&=oRvkgg|q0ejrxq0BE%i;+w2bGA!;GNcF4?X-DUIFI(^HgCJiNXH5{;x z)zer%vsayQs=o`=BX^%r)z3@)$rg*(RJ&ovUPV<UkoMa)b1CisZ`Vxi)NJSC6v$7K z9*Hn%@sSBf5GgP<kOq*N0E|4I@1I5JK4c@Nwvh2rbey)C`3>p|r0NwV{d^N>M$tE% ziof?Mp3h>$s&khme#=&u>Sxfz5nD92HdpJ`;_sPaJ0`{NkccYC*<B~>a`DNkp_b3$ zVFeOKWEzt0l2@EZ5mLk#;hQ{DRh<b`*1GFh1?7z6J}XY?Qf|>@$t$^0;v_6TB>|FH zZT_h3D8Wof@(A+YF5gdG=vfUfRD+qcgI(y)bFJ!40(rTig%~PT$9A4Bb#?juObWqF z*BdVM!qi~lj9*%e>}A}3A+O=YmNBdr_?%h_dEG&B6BzOu7*!f0rPOtH6d^YkFK%qi z(eSCPr0gzXs1{7d<&a^X)NErssDELV`s6?R+NHTIsgJK)%`X$e6WT;5RRKIR1lY)o z6!PcB!$n%DQcq3wO9~^0T~C^8E}9CSMJd&JO*CtNWq=5lO)_AG8tLP5)1ea4-lB0M zawBd`sBTVRsBxE}omgVOER*g|1m{1@k12hg9q;@~$Ug;8qUZ+R94|fFm;gKLbCBah zgv$ccOl41=bYjC^n^M<-Kl%Q{NZ|^<MSzC{cv+}Zw2L5vAbgzIi4(vgsv0kvAgicH zM&z^DH6z~MFxn|>38VNv`<EbO8s*<3K!j5waORALMt1@FDRh6eTmS6GhUnunvwF2f zA$+Ka`A8K>ko={WkU(;%e+f4QIN=tkBpiS9Vjw|y!_GV(<MeLflw(5VRN?q-$NsNJ zP5+xa<M@X=YXy4orI~uwmefqD?H*(5RUe9p)xUdG^sC~vbh$Xi|Kg^w;oKXHjf4>* zI_y{YI?iKyUlS~2?4Cc8f=N-}mp;FU;68=WzgQGO_$!)zgkJ^Dxh1rq?C(!#yKfs| zL(3TM==spLZrrz0hov9_TvYJxh-Uqpy}|iQ4eH#BT!Zx?t;dnVb3^*cS8GF$l#kiT z7=N(<#{Xt~7GdofkJMedo<e_3NcC_5M`l*FOO&-A%jChzz+IL9$y{n4JT6*uSFWd^ z?J|DW?sUb`1)<IO8|+6yiiZj0fCy7=L42O-Uwr33rEbtmFF+NW(5H7*HV4tbNo^e% z9|R*jGZMj%oFE-YYW}}G6=}G$1zVK&Hb?8GnIU2c0uX20+Fy<HkJO$tOuPTht7ZWZ zo1l*~>!ED{_$TDPWyY;p&}vbPx?3&_zM^8kW&*(|K7z-E_+RW5oY3mdI-sea8*2Z? za0NUm2HHb+szM)IN`SGd0VL=!_bsR?=)b21?!TJ)9%i<VmP2K>Kg^lR6^V6vCwj`U zkqa1st=#{Ec7)M@kEE(JjCKsWSpT-QReb6Qtf?u&PJNt9L(P9lO4!r#uZG<9kl^&R z$=S))oO>`3_-DJI2Sq%?n(vVt-JIVY6YGGF)xTI@%+tZgnd=8m&%Bs4{cv}$_XT33 zw@Az-E4=>qW#}ai$}!zrdjanfY#vhoeZRnriv(hLG#A|#1qiC2ILvYP_8MFkK<eB& zGvu^-T{u76`uIYWKF~eTVRyh@q91-)4CjIW!zStrTytv0CSfcjAd9OUixqWK={@An z*D5JZ+x&Vf_8wrG@=<B{>iBz02{{w;EZcK~pmbn6ZYOX)(vi&^FsQ)ltMjY!u3?@( zzUnAcyr6ei>Z5R0osoP-M&-)Hg>Gkogg<vLwG+q`MGcX}Uv)=0%JmOZq*2BuR&SM^ z7*()YUAB@)ZCIW?0Ry=J2A4@nT{Rjd3gxRM+Tb=+JS03MlCMYvbegW@>c8G8)1`Ca zBDN_eaI|7uDRI~g*$#7Z0)wqPYMCJHl8l|p?(g2`#Ukh;VRIpSL@iMu*}+{YQao@; ziH(>-plyMe{Z1<^c#tt^r1F)T7~O7A<x8Nzs{a$oAF63~fu74KiMi!>?LgrlC{@2j zd?N0&=U;TT;XSGK>t%Dlez{kT&*v4d^K=4DVG$#z5`+;c?|8aHm)<`8-?4+O${$aJ zCRMO(d(NNKFH6bxkDI*qII4Fh(iWxnQi2A!uP#pf2DN{cDKR~sDjS-eSX40POvSvK z?EJDWj%=0K%kxoq61--qApGpL=y>{ICdz|t%Vab8V%UcZOSe+-(W$MRRM8Ea3uD*X zoVje4DLjBYuhwZKq5N})q%L{PxhPXKKtL-fdk1H~X96k>vT}boT;3q$5Z(Li#1FQ6 zpS(kInL(s%Q}*I~5ArlZV6%q|-8IDAuoqVWB%PG8PxAT8+sqrNK>7;q`;=}9f&ul7 zc*iyh1VENoy$l8h8UUdg@er-28L_X8LQ@2=RK}oZ1juZ0@-=x8OuTZY7{rn`u%WRI zsX2WI8h2pRsSG{P!N)H#BUg4o!;<5}#|jhqL=ca}Eb`VUcnFmk4JOnwMo7#!9haL} ztA1gsUxGZzNUwvg1$=JLIGJdpm56?!c2YWgh=U$|Fz>}NwWpkB!dMe_M{zzq8?`mk zD63J#aY3zWM&gOmSXv|5i^I@w|HbC5>2!$I#)eR4IL{`rMB=S+XdXDwyy%45>|?Qr zAeF=;V=Viel#Ec3*A@AcsM+W1YMQvsya8rKjR7ku{UMr}EAkgbfVbTtS{ntK3o?NS z{vv`4@)B;iE3%|7Kf+=g%`=g*HVT(8e%b=b_p<F3OS&rT%8Y;|Uf)f*2#H=Defz3r zBW-tg4p?0-$dW^wxbHFHRrqy^MxYM!<4rNZt407AwznXn!-w=<86e@ye~1Q<prc2L z!TUG~{3;v6P$__XNtEW3106sDF9_jcyPKQqnC~v({QioTCYX*$*G7(DZn%V_4*!Xe zhG>V@lZNQ>?P;2i_yjq@5>9hFc~)c`mYT(K{Ci)5kL+Q*-eT?KhnC$CiU2#W1LqdU zW)4w1G&4bDYkhMB;5DMT;TDdrA1}fXf&lUoPPicQA?Hg0WUoTrC2!ut1OYnEAD@vN zzLQn9V(fgjL;HD+V!lK#!bQ=nSGxZx#flZHjXY@`=l2y>h2wL01vqIwOm=90mGgON zj-XG6X1=84geD+>46s8>>I!|?3kxK!|L!}$jvb?Vg*ntVcxQB1=&IQLG1VTJ{a9O; zYdT^Qyqk6cC<i#+e|0k%@<xXLa+oiKOq}LZ_Zvq!q4|~JO?_6JP@GZxdM@IQhcCax z&fIQyPSDntJ%lA~Y6*8mYtftcKn|HQ4{laB<ioK?qk8jm@8h<~!OVMGu!w*NSi)UI z*o;%|BBR4`m`kSm05ifZ%m{uv`%+i@TH@nofGH`IG#{;@wq`C|J55E5G@oo<1TNhF z(hcF`5)OM7uOYfm%AkIPYIdd4K|^h+r#H>fv$G?&V+d04ktW^BG_`+)`WRkgEsd$G z#Rbt}lQdWN`Fc0(aDmg2`YhUQh7Ux2&=11Sg^GNOc1yj)@4t=giR{Tm>6!U)Qr@^E zCoD&`fxj|p<i=#`jLZ2((x_}hTFA}AHh0((jLBxCS@$Oa9}Pk}<i<ow8XBOoYrQ|E zFeF~xZ10{C%YhtdJxTp%W3(?NJ4#6y8d1bTc8om4#E@!dNn^5sk=fuwH6oHp@H<#Z z%bS2#ve93NS%P=^vovxbN^EVv%O_@g;$l`<fSdqjpq-KT6Yp5mDHQ%F#mqjR74u&{ zA9Bo!Fg}#36LODwX>ViAWo9FvE4HEVIgIC(S(65dPpnjB4&1lqj%v(xudbEt4_n$N zfyFLe(tcd178T`4cAnZLf%#ay1mq1k@W!WP)|F(o6FW}_Ra`}7TWRtD5&arLu=szY z>!F_>Ch$PHgUJ5g$3QuncrVJlvWTGvL-mY^F_}%TSFEk4x7WG1#Zh-)ZII^~y7>Qy zldC?qT3wG)6FZZ&iHtXZLUQAdC#sK|EY{@;)>X8xIxCKMxuQ;QSJySK_Ipbn!|RN+ zuUcTmdM(yxG_MA#Z(5Uq4^6GDjSq<y7hb&3`-^_qzwpOkF>_w_rt1;Q^uLzJ8aXvF zeV%8__v45y=$C*zobQ^}5+b9N_PtJqqZ1&e_<2G$;V(Z#gz5Urw*@;*?LlVG{Jfv7 zKN~g&xBboq5XSZH(p*~`i*d}fA0{x>+ep>nt~9pWAlKQvS*Xp<Kd(7(6yvQUkIn+C ztllW7g%>5R_vjzl;XQZJx0fF0j5ttBY<R*lrS01JdMb`J#AEPPHsVJdUY2>BJkg01 zVF%{&xAffWhW+Syl;3&U<NR0*1k}LfffJ2a`Hmz3xV$ffh(Ifo2VAq<qqtr(V@8s* z)kW1gs*5RFG5-m?kk2;e4fXgk&7@GR#~9raDyxXf)XFU#xdi)WC-DkiXJ{4X*Ml{? z!N3Y9x1*CfOAl!3=OuM@_j5rF)_kO!BEl~FXvgq3-}vOF(t%04EfHt@C<=K2B4E)C zd)=AFv6#W9e|E~|$aEN%Q{0*ima)v9BX{jz0uh?**#@h6M&BU^XwjVmT*~u~YYb;Q zY9<+JBblfm{M0h6A)VDwwfCwe^4>_i>1B|SBt+Mo>S^@Q%B?RR$py`h82?Zrj6`rk zWO->$Rl%5YmyxC}{HXN$p(|?eJU+yV;qs{gapqMxHJVD;F!z#H&)2%_bx)~G^Jgt5 zysLZ0;>cHBOqKS!!a{`ydj}BFzVoK<uUapQc`Cygs-;A7s2C_eh=fp1+5db8C?qtE z<LPZg4-DJh<H->$*rQ*}e+Fz1FhOraZ8KG->#{+rN2vF<6!`G^bJjCo%X*ALx+~Yh z<vgLe!s`osESjkdpe&_&CqM&t#-orf`;ZaF>6A+bgOOp>3fg4`3Iqe*6U3z(DvIDi zREPkwhq%R<8EHTfUXu*Px&9-sljFLSy4m2*;_iK56hOd*{m9Rx<3SP#9vvq{aI1j+ zWFG?LI7Ng{-RTZ?6p|!o+`;<*5D2*WJ%kKlA0})rh7niA1S_WhxLOx%?k}+Uo)Ukr zcp)D^Z$M1n1ek3Ub1xa5Qc%#@IdLk*aU^;g<+mCJEy`M+*C-7gbnfo*Jh%9a@?^~p zN~dI*$sp{a^~^|&j`*g_G(ic8=&&s@BL(mI_%00(hAUv+s`3ywsK6N^x_z0ss|Cp$ z0VX2I1kKzCs)zV&Q=`}Se6!z5cqb4L`Y>o+@DPkJfa>T7<VYA@_lU5p?1vPcADHl9 zU)RBNLB<6S(;pXgMH`O=Pu*mS@Gq(9q?rxmKmGN@l_Z@;7vk@@4W?fyN;v^*1h(FG zRUXUIa=*rlO=FWyI<J=0bQpGs?s$7bOHeAKn|xNXOs+tl73Pr->_{N*!^VnH^X8Ot z^!ddiK`KESoaXk1{jv-vvHw|r%rmkHi>aNbR-UrDe+)YV@AC#)_<%=7#(UosD7emr zW(((3zMgSgJK-nq!fw5y-?cH4W=6K)OW*3~(ips9YiMu<s$C*?gfEWL<)af@tk4bW zZn*NiPbjCun-qKZgg1itC?=|+m+}CL_^yCB6higx52{KpA}+3kBgS`vcPO-2w2gMO z+E{lMF*hqjQ^kV+c>RhaKj)%DHpE-vEzX5SHwVJbYt6zxHOYbb@XzO{zv%WFRfPqo z`_G{1U_X6w^X0oQAhX*x;p@c(NY4r!iCZ_UU9ukG1z9b<r<y(I7_DTd@cc+kQ+pAm zQ<GzJ;h0|mijQvtZ;Hv2p8r2EL50UURxj^trlM-q9Je7$OzqQ<mYrlxthtKWWpd4( zSBmdnxrSq%$28Qk*e~a8?Bt>KYvc4A6R@Jj!R0KrlJc5gb&}U1u{G=)DI{ZDjbZ2v zyqE8ERyrMKSpqDzI9dOqzy6I7N96|=ChOhY?xDR=T$OTJLtojseRy5!IcC9>LLBTm zRDgr1qf^V+facg@{0w$WT;g>`ohvut?BM-()wxQ?_%h=i#?ejc@#nu?4C>6Lu4gm~ z=fXWYa#wAt(TdD^VY@Z(T`+v@2qP==Ds!8<>e{d?pKNpU!paL+TxYT<RZ35W$ecfq zs;iG7sW$CzIwRP}J_#6MvO#7P+ZxSnUYxu<wX*-Gh;*{^Ghr}uMbO<xioNl(+Jes; z1d_22xzVB6mNI<?y7YnMDsgOYQ9I<vFp2v7@^IdE#EagM0cX)uTO%zOm`s~y9uAA< z$70CS4yDQBp`DJOzvpP+-xQPY)QX~0T{TS01<u83=?{P6f3LTlvu+3qKbqe+k?XA> zg<a!3q`xSz+m1ATURr5JEuYLp_3Q1o!aCULtTAX&ykPZ$qwC~Gv2?+%*Q?GCCzpKd z+ZE4h4Gg5A3(uQJPTgC+u8Z4{Ry`=}a6LkpvlQda_9`al6aL~1p8B$w2B>D%FW2+r zs!pp?s>P%Ytas;ca^4E1vC+TmN&Bqn`L5aZEPf^fccCfL)=8Kh67<4t60fbQZ+|BG z4+d)dM3p`9TV`)_-cxH@^`{~fht_RXuG=$qw;FDIxOdUmCE`!&9G`G$yYnQ3^iYMB z5k;=*t4oDe+j89k$G;kXzRg+u0UXx(AeQcLbQ2-lke`}SFxAAr^mCF2{llia>zK0; zE6Vxo^L)<<RuSD%GhJsB%ODhAhO;#>U4FZ7FFHXrT0e>P)0<z*qYg*X&QAf`${z?6 zljwXz2RA1+$u`GOs2vEGBS%E+kB_0!yRBpSq>(%6mNp*mTFn!iA(5A?Q#%{CXY3_Z zI%9wfq8@z6&7{7Xvw#hum+aetDzuhRtE<;C=I-`D!89h<>;9M;rcX3Y<BZ+wv#+3M z%tF?sc%lX431tTE+75$exB5a20<%-bVNV>pw$Zq~%r-V%BHuq7xJ5~LinKxVQvB@; z<P*3=y%a~<cu?F>;`=j+A2fKq+Po-Xu*C_SHR@bMC?ox@c4{kJQ=h;{@$mdX;^kgL zAnG3ilJ`>t+h|KP?A~7GYFe{%u@t@hD_IS|P?JOa${X}A`VR<`V(1TsE&{>%=nn|D z$cOm)*XZ)?I$>O7oO{*SrQHYhRNJ417E@CHyf1WLI4@nSytFvEz2>Q(#@*+m>FYbY z!u3`*BQR#=5b%w$rhR^b=ZxTw;ExvMR}Mg+gGu&Ff>^IyWo@gTlIICcB+^il)AgiR z>NT~n2qV51R3H0$r(^;aUW_$};{mC?V=L8S)1qRT0|`{Kek?E3`V%u_GFvP&_k|UI zu(Ei7EV}FE#D7Y@B=o4(uhXN)N-e<Fi6k(+eWkhx46$Y!oUn?}7JgANE?cWV^fA-2 zY|%+JgK|ntm<^C2D$3?u;>uz%w@d1WRur%8elr@h|MrZ;{f(&mONDA(=I7r+ALOQP zNrrT%rXL*8DrkG7%>+p>zDY?Nb4G)%Yc3$deY=vj-Z645lt&;NUSqEObcr?HwF(66 z-ls1fS`uEHSo1zhG@ddN2m%Rz=Ixac93&o4dG4`CRG@6gov3cK!fi3^aQh=x*h*`+ zXAQ}nUaQtwzcpfTc^u$e<~dPXeQMstIs_5T=Xv4K(AM7`sxpi>grkG~F3yBlD&sVI z*K$uVaLBOVq6XhWwb^;uGb|QmQVunak_wNW2=N8ti#K-eboDA|yX<X3*yTeVL8Vo8 zfcd7BhT$5gGweje%bQ8{XcT+1bdG*XR#cL>F&rz_LnTeMY`tX)VVyB6=slP{^N!Pp zHsd3K7YnG>RRNXh!0ynvEesZz?sNG8p3<`BGFxiAtq&kJ)7*`kW7->b5E`qcu}V1y z$^Pmv7GxeLOtOF8@I7gkFp{2S4?5^w6!TNWwyD{MwVPg)wN9K=qw3<iz|gXZUn7I! zQqxVdvFDjgL>vM5E2?wNNa=>yBr9)H-WYSnH)%qY3Zw`;3u0eQA88+H(n#rHRx8Gr zccWiqt_b5j_loYn@i&MUT``(|yIHRN-c5S5kpD{1Ch;cDT5GYe-T6W~ZN8!^Cw*Z2 zt1A}Z*88OI7sLD$QuD~eWQu9T!4u317pU4(*iqML&TKra=!_u--e=rvPfqtkJeh_= zkeCsf$t5tm!ja+-;@E57*19?hrL5+QA3|4t`0z5-#mX0a*W!6IBRiFK|9*Kdf)KN1 zoZm+jI8JSAS|@N@@HF#I_xwSREALKSL*^<-ur{psPN}GSqCGJ+=X8g=MIDBHqQ+*p zK_GRoh`fO)bsiwR-ym|V)98BgXES+TVrb_nROKpF<$9eP<g9)VjPn$ihfu`BP}Dyt zDIAWH1X#Ibw)bYv<ZcA1Z(8DjUZ09A)?p}T?hy#BRv)9AtZTqflAKLcyupD}MBH)F zg~d875A5TsiK+uR7011gFlqX#EHWeT=vW6<uDkm9KTyd8mi6L+2YL_4TX@iS<tn)a zYG0{ZUI~<VT->)ReCxr}=(10u#bM~-&?{%Rhcxxq7%H<a_n!19Ryp0wyd*xT;2uY{ z%+aHKjtG$#qQ*q(ZD-8VWtK-a{n;LDS^9^9o~fsZrru9|kV%afB}~yrM&J~K?vz4A z@(_b`h+!V5({kZftueHtr<%;}I;2Uk^-Jg5&b!K73xEx-siB2T?*HNG8-OG0qOCi& zZQHgpnb?y|tch*gGvUOxCU!EhZ5tii#_Re1_x@T{UH4YqbI<L%-DmH;_S*XhHcChk ztEnDk%|C&$hpJ4{-V{gN94pe8sY^?xiNZ6`^I>Ja_2TjcnT%H)Mz-|T?Aipa@$pp{ zUp4L0z9hY@@{Dwsl|Gy~SE(yPJ!n!e){!0K2J2r!34JFv{;jkTd$y3}W|79~Npout zE36nvG<Y}B(+52AF@5*ja*OcNE)8!=F5JA(1nuQL+&!_R`z-L)oabn_#;;t3jDhD9 zNoiA^7)PE%)5;cn*JJkD7wA~%bMe&V3or902~OFt`1XkapQF$@UG`9soBz-=$Y=~U z_qJyTGK3Q%>mQWzoxxp@L9jc{^r6dA%M6@sX3#vx=F?pxqw0fihCF&(JI)-JIg9`Y zU5#=?DBd18`42QRe9-@ufH%9ubR|!!K=W_u+>Q+BRpbZr$M3H)O+Po!@ny$O){=`J ziXKAnhr1I6{NF(E{~OrJUH8Q_*V(qo4X<^HsX_f`dJ5Qo1Je^GsVqR_B>2NyoOA!- z7S2TxbD9r?)~@_c>`7B--mYjx7IzZKGkaZZ5`3610k@Z7&iMx+&S0~A-Ot+&=)IF* z|AD5b6_OVQgh$Q2rCz=%z@eCey_1iZPFw3%DY0%2Sf;-~<{013`VQa_a0tO0Ug{k1 z54SQ7%z*#y@Q!ue=#Xvtn02zC_dRK$=)TDU;m>x`^kuj+8%?G&m;<tdaDX!`|G!CK zcyk~Rk-PaoXzkkdq01~_WIH^IB-|WOa&$S|tR0PkariiQxEO~!L`(!tLY65p{_kSY zGgI<{=KqHe_)kx{tjlTx8{LX#AcGI6BjIKBe~)>~x0Bf|B?&kT?xTy~`~UMHZWDsA z?!R#`^=-1g9d*^}XwtE@wO&oI+%}P!2Qb-1G`ZsA5JO1|y1N8#c<O)OFEsepcti3$ zxJD1*g}QB>VDqc`$02jRhokyQj)HEgOZIbkGLIs3!i^amD{FZ;flimZ1`5OLT6p}e z1nY5MwWJiUI4(knU|`{aV2u(@4}TmkR3Lm}L8^mpUQzMf4y_zRn0jJUm>xH!v8SSu zwUlT+8M;^@3n?1uOfT7tmgxi~xEyC)x&(eNNt=aU>lzNcHy6+B4A5E4U*lHcJ6zvi z0^!1y$6bVs?Abj|=YI^<9HC9qWcKPn0#L$Pi*Er%E%g8<Q%Q<_XI^-498K66LLk?( z^9zQQ&nS3ED%vd7ycpQ*+z5!Gf>sV%5L6BlEdedP;&76@|CZivN-OsuLs2UKLn}Qk zb6eFzX@PU?4fb)RH1cyDpsvkn1MEDgPj*9sHHTyNTy-cAO2qu+p6NEUf22Fge8_!Z zSj1AQtNL*f;YP^=;e)8d_N70Uns|2HG@Aw@k}!q$`zxl*IHzj=SK&co=k{hL#A5OT zT?S<~??_B#YGt!e2R_&Zh*3xo&QuRTrKGg7D4&&=%{1m|LW;B_S)*Ap>&qcejZ>ym zv1C2^w+e>ja}9?zbvlF}>c6J^m|s7uaMb*e+q63NSh(Mum<as@@lEx-^e;TVFIYKx zyxprL1>dQHSTuou1X*?TC<)4(W^?#4LBY-8KSSZ`tiDN5RB!b1Q0ilws?Secd>J(w zUht1+<DuWeYy9LrSB){)TG*SdiaETQ9knm{S2GH#{3MA=GEjJ)XL04z=+v=dsPv(0 z^hwq511;gJ*JGd}(~go)N9PKkN8pLC_Oq4t^TvN1nMK8$>FWC$sG78Vq2bjX#E@~% zT^LmRWNLJJe-?Xr)xWpAPR{gP4Bnq4G&=|1+dh2_@tx%Qt#p6vgT_mo$G<JyOq}%# zEdf*I*h%2$T3&Liwh2zcBcyjsv&qp4pR<?SJlGVnU|XO$jAQopN}zeOLw42@7x$-E ze4`!H`RCfLNVWpmw8&;<hQrV5x5Ang-*6i4d(x7Oi5igVSGN4L6G`@vI#Ds3Wl7kG zA#{$njI&jv&m-L2{9Wn)^-l2D4&AiGl6~(}a@wp=@!N_YLRpSH0WFzs4EoHNUc^T7 zHw$)=?Ks@<`sxw#PN~;HQ&kb>)q&?T3~F_h?{539?tW=B@b5|b44)hRY;{qG4IMuF z106(D8S9v1#yr$Gl)paBB8J}e)a8-{90wzhw`Y}f)C}EHvVIo)WEeW^ln~r<j(}p4 z2`QFVDU2qOKBtI;SgJYZP0vG<z%FuztRqF9X$0g;poSi415^Mrp%63oe>?W#2SEH* zzS26(O9i&O%X^NK09?^i3FZs60uDYW9fVTFvA0XdDjZLY{UXUCLy4B>ZZy(})o1N> zl4^Eg2<-_w5FZCEMsEjUBozp2+!g2Z7vyrd(sFc&I4vO0!za?PK1l?l#Frah^=z|v z$oEX2_$%RxjY=v*m)l>A4q3ybxW?|>_WfLY0$`C<l4(^6BN19Aa(-9&-Tuco#Do#d zf;vP>7rrR81ktyTQvJ`d`=<HJF*}ZDBvq;(>-&4(y8y9b>KZ@T97-{X{O6YK3lJ17 z`7T16<y^(;DHLM-E6im?WWdj0Ej4GoIPACH(#;|C(s6xEJTIdg^woA%Q{Z+a%CVTj zy-*EDSAd%nhf!6FT^s5}(>4mt!i({;YV_^1sXp_b;L7u)XHB(cU4n4d19L_?z#qTQ zPdhDn`eR>8^{y<CRsVI;FcM+&z|MDW%UZZf<V>GjXaJo?gO|@j>Q#<zAX9d~g?T1$ zOz6i%jS|)3yzpqRM<8a%jN#vRqVgJZlk*}kTMsIgG<kcc1WD}pH!bOij*}&BD(3Zn zWfP&{x^mt;p?}M)E^DX%xc_N1V_{f6g{oazGOX^=5AM+C^!`s715FLmWs{?Bq|ZX0 z>0``d3*e$;Re*Fc2><C~JnR~Z9eOtu-vQNnB4tI51**Zb6;3`6^{Ma}VUAP{G>Mn! zTfCBvQy$FDLIPGRwS(WP;^0s5SOVLtoCA>AqiHfh>CTx94{%Z=G1474QZNvVddPa7 zg{6m4_1wK2bUWBmHOBk)7xms!@E+WiN=?!aA%hNkQc;jKeYo;EVFn4K_;7E#e8o3n z?gmjVGe!=~Kc-6)rgRfuXl1#XIi~cz8A6d>e|VdLw1)<x&p&E}w0BH{IY_%-zMbaI zZ+f=dZk(N+tD8Oy6pPel;RS-Vsq)Xl5gMT?Ek4ODzNtlc>2I6tz_VyshLdxeX!{#) zKsb<ILc?UbQwBf;^q~^c1CamW7c}xB&qaFL;f%+PGf%yTZEWR~z9N2Yu>ysSFvmzR z7LtAgI|O6Mq2@PwJuE%_%TQ^~!@y7;Ze|KH$QVaof#UBB^58qh#~$Lq0K>2v)!9Q? z)j}e!EL3WZ4VaL}3ziuu$QUDkI6w#)sV)R500hSM7iA+&(dvx1nQzO;%^BZb6Fb-6 zW&N&ZyEqh_nyKFRR=lMY1cDnT3X1oqx`_#_4^Q1Q8p{dXFaI{UKY0^BJPl$@yiAg* z^dBZtgmJNly)dGx;@XA7GbhxldW=$d3^E^T>9BXFyDO%~nO5CI%rp;tL$*_Lx_7Bu z(aW}!^#(Nrp@gE>My2-xr~e0SU3x1j=v*{z^fvxIvJYRuc_oFCP&iX)$ZPC=55LX1 zon6mKMzPF{KY`i<S>?W-r9X7vX#HMxaHuKlEe{d`Mt>Bz2rN7O;B|uI3V|<9x}(i! zzAfv~^0ZbZfG!+3Bx4E^iEGGi#>BM_#I?pU)()r*sKt({{R`pOVU7;!f<)3z_&kKx zZ^(W0rJQ4X@?&3nERn?i`FLby1TNknNZ>enB7zjzB%2+ULgb3IM4m=YVMcV#-*ek= z=QZ1GHfav+hw%c$P3|Q%N?M7eAEzf`I{Z<s^bwc}TL5tYF#_Xd;)&7F<?6!nYFMtu zxuh2+O>h@)r`vMsaj&FRy!m<a3UiFCJsKA^k=fsFwlZ~S#y^#fC^5uVwq2ci8&U)~ z&Y`a#Oesv|QvIz)vowJO%9_*y-m(L&{bJo|@6qrh5TkLUf6$7WXpmN}+Ttfh7y^Y+ zSE%*GbDO{9^T29>dqH^Vw5J}p3C{kSHYZ;lW8LZT9->RU7Bnf!#Fn&K%QRivNLc-l zSb4le80|vUF>+&k*)BGdM6;9^rA&!YRY!>Oxo{XV%;);H4kx(uwA?2>4(m)@?$}LW zfxH<9i%kxrj++nOfBjEQ%a}c#@D!=%>mP!6q*jn7XY@l#wx|ANE}70VJ3A1W_zJB2 zL%&jZhHHx##nFx5!@s<45RL$7d<=t0*M~+GbBTA1ruia_48Fro?K6JFs9-EU;Y%ty zNhoRvGme5u3VM^0;ZWp}=#u3C8GsBUp$uX2l47&>nmdXZxIdn>V7btQv54Gc09qk+ zHIHJYx1ax=cO%*3RwSCeh1#O&w?ZfIuL7XxgcJ+406c~Yh$!%V)e*{5*MWoL<_Ac| z9_H~#Da9Vb@DYf3U6B`n`YQw#)fF5T47Sz*R%J0T#;V-NV{UpzLD$d5YruT04tAt7 zipERfnBl{-dG7AiY&}59$}YKb!HpOpLv>TQVbSDpd*>x9giVv7o_T&3fsA!QSB44v zXJg?yR@BkOD$_U*rsKgghnvON>`!9r<+e9rfY_MyL2XFZ0vKf&DHs?k03Cpi0E6yW zlFlg2mO6tP<bTg!vnc(z8W-|a(I3WidKq!Kn`cZNM?ts8T&Dz|=THaYLCALe?Pru| zjJp2zGqYL@g>I10fZotI;#o*Fu_Ws_9+UT_MD5o&(#=PG=5J$Bdwe0n0Lmy*FmaN9 z(_tf{+l?0xoD5&ao?J&@FNfO?=vTK!`ZBlw;Rv4Ta+?e~Rk(SrZFOmKq0TY0=@V}K z6V<e~6vBSSC*0U4gWfePgwA^WcX(#Gev6$Vk(`4Zt5`#Xb=L-&ViNOg99hc8p@l1g zA3y;*+KKW&AGIH4QmaO(d@Ny(V@urpigoykZK~@*vOjFNheABLKk*KnTXZa`4_PBv z5qf=Wq@_S`C_q9NzAZCpFjU>{c04Z#0Y4Gwq|gf!JZZad0Ts2o1HfF6VM%`jAOVnw zq<~#$05|{~`_gx4Ic8i22EX-cZ%+3~#T0Mc3u>;-Yi%RGvS`c8*_x){@}nZALZE>^ zCN3S`5~;BSOKSZ~I)jOjJ2t9yuR^zO_*80<QnkBb=|~%rYuyU|_*ifrF}_u53XB9& znAdf?>zZqy>as<s!dkte{DK?VX+Lr$_s5?`_}^~oy)_qMC{xs(G@LBKS(k6Z9W7`* z7w15~;|R&5M<%(eCJj?c2pp9!;5(>vD%-LsbW>kfO&6<Cd=zv=u0H*}%@^mT>fX+) z*;6h>cty|0BCnucJA-X~obxqMVPzJ3ca=VqMVa7cq)mUgn8nOu?DV*3nm5hsb(PfB z)uD1E{P||at6~VA4UXZlh%eb4!q)?tng#>XmZo;)Lk6?ENf0FE@F-L+_||x}kWzkR zoL_t>BK6mKA88V&E5kY3tRb?lJXbZfiYza0*}Ayp%&(SQ;EUY8P9ephTEQ(ydl#}c z$;+ZAC2&Rt=Z~WuhsKJVU(RTpEa+gOlYAjt&>P8xl(z-IW2LMGyg`eSiRQIR<h|c5 zaE0TD!AE`|&pYGmzr-!xbobSf!vq)L)j0iq+h!(akv|m>))Ur0^e5I3-gculiR(wz zAdo{-+Ccl7?ts(IB?cqbf|81W!<138o40Nkh+%=BDSCsVVOHw)?z0G>9cNX-1c$>0 zO-OdzoXrj=c|;9!m!b}z_hHtP64-#^nE^&*UXn6|!hlo$a&~I_ZsokAMnSo+cwlD@ z+Qco;njRKC{Tem#QBsl_RSn+L-sOjQJlrq(F{DRaiBdXT2<Pv=!D^<%p`>pB-vQrK zmA;pTF#xk)YDhmfYp!-}p&4!Fclp|?J}so`{`&gfnIzXtJF_>dQ|^l!^&uro9^;l$ zrt&GGEsgRs3dJTuCPE<CBjMDLiXb95$zVv|1HK>iffsrK7y*pqB#iSWuRWX+CGpvD zS2@}pdXY+>Y<(ESL6cP{CQ}(*%-I{BLL97=Lzf`8u)bvV`<AApE=e4ztMhF^BT304 z7Y0?TJ0=HaJ5>-yGCw{`mhlV}71U2fxB-_$4{+H%KGj^PEN~`DMsBc%BLh1ORT=;j zfaw64DQw}!`0yKYXzIowCY6376=7m#vKqeC=Zi%LnmzK8dlmi!sDULdTWD;nD<!R) zu+x`TxOvZXec}89WAVZHq%U@?JI6(mKs4hZ2q^w-y6HeMOKH(?Fn-Zah+K%=zV*C5 ztpOPG^pZTQ7iZkF+dtyS8Dq-69d!)p-doLXXODg7_$wof^w*=Z52J9=tTT)3XhHFt zaKXAKvo+oFIwqlHJ&p|2HXm0p``LyZ4@N!Iav`j!HmGOfHDreXNCHUjk76|ZW4W_+ zF8wJ&<qyl>@2iGk7S*{}AI`T|9-awRa}Nc>nCEB$t!pgXcSm1oBBN!YBiVimk2FQk zzz_%B%gn|*VT^Kj+PBfHC;ESHx&BSgIt#sXfxt8V8SD`3FqP$?SzRUYP_4Nph45xx z57o91&M+x>>D2IMbLr~fO-p6JoKXk+balN%(!Xo`X!_=~aKzZ@+nE!)bn=Ty?Y8NI z*K4mxB_a>cs%$@{BWZVPb?j471KPhyLxWpG_M^g}vbgK=uLv>Xq~UuL0Ymu!%hbz| zrqat0PxZ?X3(U~zJ8&)dA<y5(c6)6y_x*hP$@KGSGxvR}=Q{l3`d#c5^m(84aos28 z&Cct$AmYbqQ4Kw`GHu?>{bW?~bp2!hPs>92$8}cEwb$p<OYXbMrw6+h;TCQ~7cOBJ zp|1ZOLL2V8S&m3(O10HqIg@Sd=qgD{;})%T*I%{851+z!t4li1jgQuE)`jnbV+ESC zejN=R^w$2Wi+;dAh3_Xl(yxd9_Aw0xmqMo1Ya(}___qGv7yUXa-cMw;UhC@~2q?~T zM5cJQKEJFyc4<94S0?-U;h5%ZnXMc)Yrj0}CHwm6fKE@ot=zh6J-pc_3HeW*ZwdOR zydT;(_@AMD8gsmzQHJTbEEOkX+SclIAvLm`o*E~Sw${pW>KJSl9t-ImS`axW?xkWR zvvKRJy)?DDKe;rw^_8dRpR$?MPvIn7-<HY};OCpnz8V_4o9LJ14`B0=m`YR1hSKoH zkSuqYxAIi<QCV&fMy>}Ekz+!;vDTdG*;!M*{y}r2L_9Mzgn?+|!(Hw`9wNUsAX@6+ zy{$qw|LTNdx19de>PynRUuFC5@e<q+cW6w_AeqPYZQT1;(XZkB;ke)MWHL2($<!8c z<7`Pi@Rw2n1}Z(5`QX~W!Lg$t=<igFDA!KR%^x9^!9dvY7hL;u-Hu&K0ws)eY-%5N z@Oj@IIHrUM`xw<#K6c5kSv-50t&7iS_Yf09T(MY<ZBm5anV4j<R&8*f^%9HH<lLAz zNzx%%5%R~thKHf}uZOLbupXjZMd^IdPk$rEw1X&yNmfwycXYXn_RCMUGR*wna{XnA zpS_qg+PEDp)eV0`>T%WV6E+hUQ=*659yQ*?gnYl0XSoUZ*4W#P&R2DYtkSGoKcZL8 z({W+s`yM}YdntPH`V2MVcu$?AY<@|(M%O2|kpVtl)b6L>Te;3G3UJ|U3YE1r?&P;2 zSIM#1gMVhV4HV1xDm&NuZXgaech<}}i=RJ>cZt_`9-Ty&xW~Ag!(a63ffsyPlewCe zr`h5<sgyM?8ZfIpxXc=KFILs@Vdku{knqauGj3k%^!G#YW7!LvGbzBGVZRsu&OqPb z<@|M^ujA-X(p?y%#@kw(T@lQ1m{ptmBu+HPm4MB8&M$*KuWBuk&qg~<=WFi+mX{}O z#TZJuSar$h#71Ul!Y^^u(~t+LRx*S=>x#?O3}ey6Nn>@T#A7l`ai0fXa1_J!km{EO zFVfkj&W_DsrN|%%8<cJWogdtHZb!8BWOuXNiqc_$ByuxQRrl!f`gVV?S2toU?$%wd z2ipF&>9r#%!JX27;!=LLqxD0sQiWiAX;D|NwMz-~oSa=BQ>$+;Q=I2%V8$7=I}$EG z^nH^hk;!tCexBZtScJn4#et@auHXnOgM7g?lJkR+9-~cE_>w0r-i=G!pp@4YARY+9 zVYE2PDaz2d+r;ejTdyHKgR?s^7q_C`%_n8*0ei(!01ReMm0QXwg9u3uHvM7kdrvws z$sJ1qwnd?a1u(iU2xHfTeBDk6#fnml*wveTj4K1R6W_WM5Qc&cF4vNq%o#3j7=SRa z-?RjV0|4QG*}~L#xkZNLb@BR(63J^fYn!+sOy1(NHL!Mr(-Ku=Z|1f-`C@h^kRQdq zP<z*RCA*BO#I#$g{&{SvdEgl34Bx2v21FNM8?-d9vmf>27MD4ylAR=qGadyr0xNW2 zvM`3kS;Ok#jH0iRF-wC=#IR_?kxSV{lbl7dKuGeGO4|8Z^fq<z@-iqtc~B7>o~ozi zsNPm-VBp0Aq3%)s(bY<2ElJGQ(-;%ce}~Psf}~O-4a*&(Dwf7Wyl?#}K=t+ctGk)6 zhF_6U2O+J$!82JsbRtQj|3%{G>9d!ED@l6rwPW79xW8GPF>Uj<^+5QGXNP{{{m>25 zNEpl|6>$pErkvs!T}s#^+j-0V!wt4jqWt09pg+*Bt~|G-ITh|WExJs^S=bSlBD*dE z6+u-?9O~zA6wGfp<hwY@Bg;5R@ZW&nckz(3jQ?Vy17w*+WbUbKtBY@h;x#Lmd#hzw zEAbggRsx*ig+9vznH`%rRi};bqiSP6D?=sLGTmMmb*Jhy&wi0+!pX~o%A9Zz?PSUp zwSNIulS3oV424U;y3}Is4p0Dt{w+460SMl85{Od<GXxm2`ZxNXXeMdDtY4<@{F&8T zK6~{L6{*~P(J8<S@^_l7TGYt4a^FfhwRYbTtUvLJtdxgKQDvv78BE7pU9iH6HXh_( z($fiKg6Ds+xDUw}FgeE&7gjJUq$CU>3~5KbiFXNpn8}7A`-hzB{dS!pocDYIe~;Mt zGAl=y_ffjO8KFz})4ES2oac{v?Cnm2&Q60jALVV&{j8h&uTv#|Q_}C>Pgd@iLMIG9 z7%QTZ(fzp_`DrOT*;qYitDiSTI(c}7X+^q(E&SPO{Y5}Wd={U!4nE3$e%8~QNHHj* zQ<sRVn$Y`~F#C`0Fz2Viz~GzL*Wib&z)u=elKrE@Hm`3Q!kbBrTXfc48mpb(7C&k# zd|oqd9%<f7j2hlv-LzgCXCLnC9$xwO4>jeovo~XPD9#P&Q$!LQyXdXEG#C9U8+<LF z?~>|njtjQ_*|kTIU3<4p^7Usq*D*au85^yhG7Rh>Pc&8x>5g2BQ|CiCo3bx$Z7a*! z<-%Jf;91J~26Ga;${{>mUWp}(8<ROd&(A`ahkZ<%=Ag=3lo;6`w^jbZc_PN9*ssRe zswo%L``a6r?&0fC9#++mc=9OBL)${!(*>*dyg6J+ubB#(pbVqimB?RGp3F;HB{QWw zYh%8e9HhIiD))u1TPwUR?_nt!2ugDbu%DgbtqbI>E^M=Rn|SFj#4O?t>hLVSCk}-Y zfwTjR=`}g#Wh>z5#UqA?nE-MCIc5awZ{|g~SMvbaf4Iex2bD8aeY7@`U7j=^j;>ZJ zNB=6;C~wX|^0yQmO4Q?^bL)H3pKoce+bc}C7zY}cs8qU_2i?{MZ!$O0clD;I7#))+ zZ1t`sZ@AAFH#cyaaopj3d?S@XdppnmSn{%ZFShwn|FZvg!>-|@qy5cCZ6m8EMZ3ek zd*Sk90#}dNmg_C=KD&#>eQ-essOS?Z=YOo{J)ZVu?`0#x^-)3z(UkzZ;4`1yW14t< zZLAWi(%OJ!ihD^h*9yo%<irI<qzd*6E&%!0*m)HM5jY5w1Vk5vnPF{S0=k(OH2dYD zhvCNyv*45F1S+e1%N6ccA6h-(yIg&8;9Ks0ZA`ac&CTP6uB#y$l-D*oR(0*vnbSSe zrRNzh88Otqi~d&aiHuM;+>HZ*!w@%`t<zV+{u<ThH?ow-CV^_WqRS0+NWBNQIF=aW z6GZ@FtX=F^`IhU@$wEzon{E2B7W@S8ryMhjt;V073MJ-xeN4XH@%~w8j0@nqF^m?H z^Mm|{pX++}TW8Z;)o9W|ullWxe|_5&(?_>1&#(Dh!-hg+f^*NkUb1|(p<}#wHX-Q* zdKE>7GQ{9wtxFlyemg+$3$6<UE+hz??xCdejeoO<Gv>EQGzB!1R!`}Ki_N1%Al$?H zyHZEIhE@=z%WRo*^oa7?Cc+<jO~>T1f}T@jTaQKI@pfyX)vaR6WIqx0@vNhc+$?)l z@C%nPW&_zXX@7@`=*!767fs@zf}`e(!b&59gJd_U0wLcmi%N_qT25;W+^Jn(DY%%^ zGMOY0-{^WF6M(Rn8xmSa+a1nd+12G0Xa1Zg^d)(Z)QFd7h<tO!XM84DfB0TkE3abP zKD`P9#6`-?4`H9wF=-s2Hk9;1#R#J0)5o-+C=nw-{=x~c{W*3nW`>Z9lsmDWH!-O9 zUR<|QCL|(Ravz=*{3)pEd8FSM%aik$?O3sL#`4n@b#=*gOWW^s>eOfM!qC4&!)>$s z((cz4@1F~x_K$$5nV;}FDz>3l&2p>5Flk)_NM_{n;FwIUw=o<=?MAA3rCCw(j#X)H z>+p2>F+eoxr37%3BTn34XD2;b^pUol?6Ud4T_odj-~MhbY}{%#-W@RiH8^p0<71ui zVMU{y1Ym$+Oq^@ifcu;2$(=2vr}HpG@Snf@A~my#``e{cKlBx*OXJclbfZs=3D#?> z{5q{ZhF%`+OH>$XkrTBn9j1yrZWI+VucSEDl=iM#7_tYvi!werY3_q>WB41YvbXYU ztC}~-pFE<MBBSLBxjq+)_tPn1#0j&z%+cwmNh)tddL%8KE$t8M!zC9_5}?n>Mhm7T zvk-R$fs`S-oD3rNDBo#C4Z8!jV3KjKuo0CM!pK)O8DUIsu#rnS;XUVoz2Lp6tUZ!y zo35<=TranJ!l4P5_@wl$DotO{r_ZEc`_~_uB|qZ8TfMw6&o6^C1x~SEPWw1;bGCi# zf<nz>TyDRxJ%k<6hWuD`q>jtymfqun!>)&WO=!3N3xlh!1cmGG#Bcmm5LOn>N1os- zJp^C}V^0%cZ@sj8**IBFw${L(>Ur`r4Em$AmQ~Sj1+A3(-sj-k)?EJxJmtBFl|Fd* zVLHfuZy<Tna;u7Gq-KE8P$wH^(e|2-8WvHwj^9T;PA%_{n+xDVf|5ouf=0a+A18|i z^b_{`AZun9`|%<jzrarlv&84teB(AZARL>%`}#b;%obhK7Sqf>^ki)}D4P7dUYD!L z7NvFKR|@yL_0C>$mL>IQk_L8;2{|1(L<T`}OPi@MOM&dXCX!eBHLgKo79cGk{rV>3 zjaDfHYdhyF%}u<v5hQi*rw1o%mibk=(*4bAqj6E8#k<+*;MFgMDZ=OT3^6gzdv~vD z_Ivr;_v)`+K3w07du{BQG1LW<!%U2z9>u<nBF3YYTX>$#Q8vw@1_zNEgO~nzR>?(3 zgVuw^tOvLp7a&lI0R960juZXmv?-sWed53?YAez;$VgZ~H0vH-=<ivK9eiwY^YFOi z%E8;j9h-Mx%sN#6Ziae~jd5H`L8wMSasB~f4EpXu(vc{j5o!n3BDo-Hp=&SnE&w-R zq7`Blhl&+2Wrk(`Eqd@9c}4~uGvo8#9oBE(EV|ky@4H8?vC3c$O+|aoC#J66{rYrm z!WLR|B1uAml-k4P7nKQ1^p%SJP>#FfRg)=zc(sY%=4q&@9zSa*nez26#K1`A7!y$8 zlVt!tcRLU_H~?XQ@By;0aS{G3#LPsB^}`(MNEB#a2lp`E3qBRqsF$QzJl37`vp2M{ zFgWidY#_|sxa?c9by^gvO5#TINO*VRP^L8AinEH%UaMS}$uRBIzK4{@k_!s*FsB?L zWUwt40QtoMfQJfz0l+xJz-(0F1UXi1mNmjSu%34GpST|nxXFyI?HA=L^~uG~YWwZm zBh%L0W7W5g?T$=LnI4psmtU#yaO4HT$5O;n|G|Y_9A7b{Fvq804mOjtHVuKt;8yit zasfk{D^zd_fDQ-);}pFB6NM1%TNmqV9yOzSe_O7uls>8m%){$EuO{p)@9%R4_q89R z27iI!_`d7&Xkq#oVSj--|5VkQ9Ygi;M)bot>_LO}4;FA;wg4)r_fK~kIkF`^6HTV< z*_+2WEtd`{bQV0D;gYjx)UH!_92-~!SOi-*14lXw0ZmbPh09i}uvJ`IkzD(Ao{QGr zkqjQG%2xY2aO;nNjz?FAH4Z(AeCSiIf9olaDeMNne6Q4w^0YV<VNFrI6GMZ#G({EH zLPGMFC^m!bUe0nd-cWp`00dv%0K$Lu2%)Wgp%BjB5Fn(U`YVa`8I$PF$Ko^PhdW*x z1>aof9R<u$!ebFy7x1XlCo8*#Lks7%s0wz(8NMb|QIrR>inGcTJ(3wl4>G*!WQ1la z)$0eDl#T(MHdCC3WdVFJd^uhB=;v#G?BE~sdadCSHxngNZGQS7eql-Fxr&G4m8X$} z-Q{DcZ*&pGx$X-1`mC|v@=kCIMRH~3Gw`Lqz&R1WI5TDw0T?REjgMdWFzihZ{?$vI zf46iXdlP8cRBc9hM|{^={TQbV@Ivtt89Jq?7Wd74Gj7s?(#g~BU!<8D>2mXY!|%Jm z!)teFlC3@xx4qrAxYhXeRYbwB=WtW*LRO|vMRXBus(?pQtGG1fyZ3S9VZWzU#}CN; zszky@JwQY%v}nXjuz-+@PzVw6LB#hMQW3BSs0dTy$bC;H(VTBjTW%wgJ64MQyHS}o zf*)@$hL`f^X0Gm1<y0@kW=^3pzVPnr?C3#QsdN|+yNRe$9d#Ps!A0q*J>Sh5N#TXD zR74k`03pJpqEaBn9ndSNXq0*?MS`-tWr$AkT>nFW%K*tf>~RW)1F-YLukYZ~+S>V* zxGPfjHde_}{*H~uJD&XuIw9&gC5Jw5%8P4xRvqk@(J$gqlp&lL=7Lf-*a55)<g(Cw z$N*RfQP7(QG5jH|5RgEE4YqpklPP!7cS{`!<S5gKx8_me$TgAou)o$in=O;;XimdY z7jJ-GX%pK*j^kr@)i}zPu&;8yh@4i(cmpp&T6CRVPHjSYQgeyY`S8i)LCTMz_`fkk zOT|hL6-#{wbQfk}bQ~)`Q+J9Yq$h0Xb_qz>f4pTM-M#}$vg*g?fT36KPr>DgTj`Tb z=8_ibiWr1txe;u^m`H(t3?;6px9161M^s@rH8zIQStRupsBT~-i6uMTTTN1@VB%ro za|Gh6SC(d(%>}ajzbk>`*lcjV7mnOymza(Lyu;a-4ezD&xJe$5P9}ib84015)GwbE zm#qZCWM_E0`Z6|*GlNov5$ZFl*jTa2l0ibv7~DSeY}%xukcEK4Ck|;aJU{?u0H$*| z=0f>t%sE8pQ~PhosjyMb`{(ZA(Sa}4>$c6o75nl~Z!MNp*XOt8(eJk>``0hpc4a{} zDT9~eXIfF5xY!E0Ar?_wGumoBOy5+j%DzhuDy5QmnAF92fT5$Hf0|PgA6$v;itQe< zj8to1K82MbK714RF>?lo0P-b(Wi^IwBs@HSOxU@eI-MQ|CxA(Cb8uzS_`1a(m0Ct^ zbRd;>D;oZEK?URZGJ6^*pUSGt(yRmf%(?QSF8(fxdea%#M<@cz2m8DZ_iS7Fp^WrF z&)As_otu*!&tuFc(K|76GccL8(y{#H*zx-Q{YUYBH2&zkEoPcVgyb(gGwfEo#Es%? zXuNz_l=NrP4sndp&looS2p*_w=<5%PU0-nLN77BwO=p@-hNJ_>1wp;D_ElQIPFi%o zfq*jhgP-r9g><JCKbLzJN7PoCEAgbp51}e<_pYplE<Xock<dPqyg)d`Ms{3pn-JGU zXA(}ML6|YNg7#NyqE=iKq{}ttR4}ChME|<&DQkngyD7mP=<!nsJAmCdk)3)bOYi%! z@3Z`{bisT1)l#~pOV`eRy<&lieN#%?1BbGa?|Zm)v)^X*!u(F(%(bjAeH2>;!QEjH z0v-jB7xhn?n>?!(=a-)h;kEgavh>vQ>#^ECXfJnZN<|;&<j|x$p&@<kLejj_Pj50$ z8|P!G-n^?*4e2WJ*;(2JbHcFar+@wR0OoOYpR0X{tzUQVz<HjT`MV-Z>}>ZXl05yk zYUN4e6u$@I*6Iph3NUiUB;S(O{GzYR<s_4I5(b0aY}CsQ1m^^BK5<w8(f}Zn1AW`P z`f117l9I#ynp3fNhVfRu@aEO4nVxZQc6i<AcM#?OxZA1&20s+Fph`fjl9+0oGk6q4 zE)V;Yksgx^+eh4OGonX4szh6y1}cIr!tv6ngtZ>U7|~eJ-Pp(EVt2HhjQ{iYx2}8f z-}S}@8-~XH&XLTh_VPc%k4s~k3mKCmy%&9yzJ61xRo~k<^#=pn4D(AM34XRFFRBrh zvCpz8)WEMs<D9D2hyGAXk(uJ4d_9K&BL@{UfvT@acS(1hX?D$BHQ5$`il;A)iBrrX z_Wi+iKF#rJ?EPJyGo^k4n}W+NBCY5K(VvF&n=n3O8+R$n=PgLgVe=D)r<}Lrumt(W z@(5LtSiMSrx_AaXK<3yK7%!3<&9lo`2%Nys@75LGhT0x@tL#y-TD1z<u6hmhE;8%D zGeEyH#|h)9$9qZ=!zfvbmABKY;%v%UK7Q$4brHB!aG-IY&2}(m%abWE;gp;nE{ci4 zvn9k@R2hdr`mxLHT@>bhm0O%%i^FPy0+@yYq}IX!0D#m!pEs|{rGjSt{aZTrde}bD zDn^-;dRpDm=^Ku`c%@m}b=3pt)tPH;yX+S&oC!IyaEyz`otm@8Xax2`hGQM}NLtcR z9mld6L<puy?ZN$9x-RuD5ZS>*_r&%N754~=SK#efdxz>tc1T3pSUC$Xo)gLLf1j;$ z>VRSnVwQ`hjB>NpnfJD;AF^F+@LFgM%Jk9_6@~JUG<^Bc0!g$11EjTyz@n~x3$iCQ z1vtZHR}{!DmZvx_KoClhNVw6vv1+sFYaNR@(eh<-7L(NKe#V-Qh(3Y(5YdC@aqZ6L znURQ(_+)<P=;K1c>+3=1T-M3;W#VhveaDZWuTkq?b~_Ld2@>>A=L5;)IJu5R2ntjX zE$OBU=|bJ*AFm`7Im$$Fw_&!wl8BI&1Fj&hOfIfKd3?_Gg|(es7m;IqCP5MT>Kh!Y zET&&)gf?!VKAoF}=F*+vT(7<*V~}s}^&G>Ls~Mv^Za|0k8sZrG2OE6$wEe7oY<5Ix zReD~uczAh2C*AGtg2gDEA}k{nEjgNsU*oq00m+u|WF}AvlmI4Qi$HnU0PKL|=M2hL zB1zfdk_BdI4R%1!##UR~knGT90SV8VQfni^U!1A=qkuFcc4G=IIGNwk1**%r(Mq3t ziIzJ=b;i9;!)U>wxCv$bsX@5GxR1>4$an~(gQSDbG=ta7$n&_q>G<vwdled-rG8pe z6R%mmuDZSrX-;oG_wqv2$I%+UjT60K@q}CjA*rBlh>b!cBQ^NXFo~*%3a}Bxq~>7R zVZ@-tE)dv%U<Qx|lS)W`Do7g~c_JVCTWd@st7bJKMP{%15sAOdh20Hs3Sb$6dlP^+ z+~q;tpaQf3+6TzmzrDDEW0R~(i^EGulu@nB<OseB4g(@jH0OP`X?V6SFBYv4@*jFa zqYJ_~T(ij~BMbUX2ulw(ajm}RmJQ{l<6TPTD#8jG0e9I35gc=<M^H_mcaeAXBLv2m zf?I-HrnWG9>QAd?T^3m5-1yc=`w!|dtfv$cSrBk-K5j>M5Yc&c6WaD1t*zgWuiSLv zPdLd!V_(zSxbxa5@_vVC;j=o5kt!A?%`hk!zff@u*B#NEsoYf^gM)tNSYVdj(A{CX zPB?K|fMviktNXIUVrg@EQu%35duXa2-F~l|W!~<tAvC_t%;3c4=m7*-mvKQy*Zs`R zi08)-a=>gq!4I3<bb5KrX359^OgUc?bKbl_N2wM#XUHNmgl8PJ5fMfO$mh#3kv+D6 zx8S#_thX&F1Gb4nts~8SWT+h5?d{ytp)s4MX^*#xIg9y>^L-T-j<{_aJ`o#DMK||q z6at<{@1;3O1i!h~^;Rh6TG$?C${N%V84|(fWrR>QoG{fA3EVz2aFVEceZyW(0yeTx z00sa9UOr$PtQ)dBq_r=^ZfM;%=pvyTnHp!<1~0G8aY}Y7l8netG1Z!>%ZUvF^=%U* zCSUB1_Hd`<uiUT%?6?b#6+vY1<6$OA&af5bXwuvaN8|;j<u>H$11;t~_(5S%AoLug zg1is71;9c%7i^CRfDgbQH&8Wtcijd|@jG0dsaRLf602_ZocT_5)J6DRDo=*VhMS+M zoC&RR&9CtC>0~pfvNra$^1-GTWg`+?@?niai=oxQk$Uv6(nlx3aXSeZA(=+bfD;$f zjYqM*2YYipWQGXgAHwo>iVuQ=qOKv`Dr4!`aZ<!<&wEsOHM|IihVX|Ae|j%H>y1re z+t=}QSYL#+^ccNFS#(S`G`5jeQbkt#%(x)OaxOJox!&PDD7XR*1eKg(Zg6pUa&Xe= zP|>alx+gerBoKry8_}M0m!wcp%2&DWpO04y(7btGl?jYiS7G0PjcEP9!z85xlEE6* z0S*5!PSD^}84|u=Hee8%G-*<#E#tISl7z>SPuRllX<mfhJf_~U*N|x$%Pk=Ew{+O` z?%cb*2xl%}G|G-XL4}e^!y+(rM~ce!Rvx`D{}(JwpQCsZDx!vmU;&7Q2*BV&%A|#J z0C!OB9+8e2@Lm2y2AzOA+;kz)WNf6gtsJ5W*JGu#gpE0&>BsyK4e8w6)#TAhBD%RH zZCWHC(aqqSNs!;i(eMTevddcMC&F^d5}G>9T7+b=YTzD&>_k5(Y+Amx?AH%i4qnDd zd2K>AMgc*@9SbkJEB9Ac)g4l#aE6{%m?m=KqPT~v6g1>||L7F3I%6WIS>&=Dhv<3+ z9I5M6%dNPuTasLfVn7Ua#|BYD$EuW@8&yU=QGEw)A$_x1uNy1_9@#J*d^;Bf=I~$e z41l?XhB<Y0U;7Hq&Ov#U&Xllramlflb1BF?kK9FK2a;c`4>sqJB!1TF@5OwEstFcu z(#QfW<Merr*mlUY2|#TC1cG5RYO0*(jmfTl;0GSooU_CF7;M{%Zi|6%sIa|O{9+|U zHN=7O-0KpSYY#kFtX*3Bn&No>anjeue#d0?)4kcF&XpE*Fseacg_1Hwyr}A@abC1? zJlye&$2D`kHY{(pY`2lQH0o7nK%cG<EHCWSI^2_X<L0&Z&WK)yEYtgqW8BvWVYSh4 zr}N3*03L%Ba{7&x?aFewCbioIR{m~3Z)0eRc~oKL$7aC~UTcC1|AOw>D0?al;h+b7 zg3xF&MHv}doIn@~p9r!RTrD1-xR>GUE4EF)Kr#z_#60C>IM%|Jb@yR%03sM-ngAjl z<w13dLd*w$MKeR~ZZ-})wrKC&xG@57oetqRRzM_J=m9zA&9a~2Wl*Rxr@sJYkmjRw zsWo*cqz4=!7HU8czo;m37J8JCOxGlzGC_UyIyL@O28DN79f6jvUkJea(hZgd2OtCx zI>QhWFfCPxU;^)xtSlZ5S(neaQ<<;x<x4@iuiT%aO^?`aiqHFtQm{iJmm<<wYEP*; zK|Nn$)ljG%qUNEi;ePi(q7_CZGlX!lC)UCtvh@Ul6Jh?7jpT^I40{4x04}KlF4CWu zen&kxJ=gUm>2g!bIV5Jf0XvN%<}%^q<#kYNd8<c@Bz~ZbPGZZ4tA_&07v3$c(r?I} z=L%}tMpo_|WxcXwLC*71g;Xm2aKMK6e*S?kL5Sm=2;)knLTSJ+f!OHN;M2QPxllh$ z{#mbaB5mHSBb=XR#!a|0M0eGdBucV+M}Jer5XZhA?I@0B1&{8a02O;V`>zDD_j?2L z$1RMDhR9(RAX7A;CBdEauSBt2lZQ};`g%$Sz#;q9Nk#w3)x1*LLVREWV1U3@2ry@X zZ2-3p^7rG0YJ<xM&cdbv=Gl=hL|Bhy1HLVxvKE?wNfK=yo;gv`CY?0M?#3QPW&0md zGuly5v&*VyjXwsB(!y?kBiIaLbVu}n1B(A-2IN(s14Kyxptv984jO(enx>u@sTj8? zfx8T%fvF)iy0};0z|tg35I?k6Am3bFXa1QKp&y$#X@)LapXWLInL!_S^~{r)!Ht)O zDCPRt+Mh^vlG(rfR9c8Cd<|3I14B(?<My1r4=!<piJM<1%XfI;q4hF?(uU$~G>I`U zdH^YkBs#G!rqH*jP*1X)`!eU&>%*2PhNDC9HA~3PP2IUMw0=%4vlYec@+HmqGK)Hu zA*{A=l^&awO~dKTf>T9ubZ(6~!UZ_+Q8|%4K4+;RAr0+q7{vM*ig$x7nktrhsF-Sk zUytOXHBoxiCyS5&Fc>so=xxC%?6dp4G95_1{CVaQHb}x_l@gu=5t!0d>8rP6Afmpq z6Z@1tf-ne4j;UBp0vE$ORfgPugPH}LWHlvc7b}Om&)Q05bTCTCLj5hi@U=U1*GYs_ z0L%f*!RVt7L%Z^BI@QQ5^*TK~sW%v%jd={=NFY<~!?Vrvqx?SjW0`$z3pqcF-g*ZV z_tc5^f)SUwOBQ{|S>?0@(jIoC1gc`^Z)dY|83mU*wL!7U*H?+5N3Z}`?*hT<@Bp{~ z+yi7>Z4|oz_}#hQ4?PY(I;iX2jQd!T@ndR#$~8ugqfejv^*I0e9=XfP=22B8r2BE$ zN#$;I7@S6Ou|nb>?ToS7jF4LLRKu)6F#mkE@%~_t&o-4sx;8p3D464@ZRAe%{=eif z3s^NSnmf@%+})UhpQmf9D@#?3qE*t#in>-%*SUe)EJ+OI{WnDznz&qE#{lPK{^Ym- zfeK46AR;_4&v4!t49bU|HsAyT0Od;?zz6_s$rIEX9JI4{)_7-w)Z|=Tzc%??iD9|` zy_KB=2G_h+I@79Oj{YXpXm^W(E%{ax-kQ2i^fT(qiF^b<y|qgX*fpw*GPBo-P_Ks4 z_cY#3KQJU4dW!(2Y%u(vV|c?*0FWDz+BNOxkfd!&ixJ?b@6lyWFL8phZvs47%G!8p zZTB{swz7-ps)^H)1e6Gq^8&$V;;o7Wsg}`yL*J!y#1N`}M_@M!A7U>L6`d=C#19k= z7R3Qaa=s*;CY`?3l*w%)UTf50m|PIB^x;=k57^Z*ef8dwU;19TWFIbmLg340$J7P1 zoP?H5DeSzF8<A@CRg1OWHKF?;abr!vj+@)q9HcacT_hKW;~I}6Rhv37^?Hp+v6}Up z##6++Vx4%H7XKW%|Ln^-7vKSPBtC$XBE19{LK^<KF#K_7_}=0jeaE$+`~A4n)fZK) zUO~d+d^BzsW$A<Ny14>UdBhTLe%G&p==acV@buqR?;~BH<!@}^S39R$;?U?+{uAY> zFpIAc3);p;s0HMfHGPxhLo_z~oS=o&zIeq=n>Mu_KzIy<{UHd50mO_G#rTxlXy}W< zrfm?<`L3C+K)eUz;W{cRrjT5ukoe`xVM-JEvR0DK<;bHtEF(-(^$_CyDT=3bRqNp= z4$hTl2+QOk1YVOWTQ$*qN-9x(vT}jnY+b&Pxr6PI?D=2p`R{w&RuJ^~MFZ;BKAw}_ z5sM6Z)^=LzKb>v8FF%gR{a$ltySOtLj(%(JL}3NNF(|mGqM=6@s+0Z5M4=|#D<ot& zx=d%5^iAKD6z@(Ymh9Ndljwwq;E#BD+ZVW#o#?`LecoE(-!T<^SLE)w;^UixLZ~a& zTy*n0<4Ny2Yd#mL#SriGPfP7i+?M}Bn5Z5Pzr)D2k(CsMvg%F9dpn;eN^NJOZ1=Yt z^7%ORs!f1EunnFM@fH#{t`7j(9*fOAKn3C49L@K5ey-s9X?`ebs@zuHm@63X?0m|h z&dbA;<bt_rZHU>KNHVQf9?(7SC25ODPHzqy{hf{hR?a@Cc=f6q>#;{+7?8Rx8VF`g zj~GA)K)v<*ii`&!Js>@BZspI{s?KiANRl!bcpf(cgiWMOw4snp#l2n?RO0JP`uD9B z&wO0>zy)pH&^P}9(^T;Y!}>aCOwisPLaxBHFb;{)SVJ9sdcQY-Vw#LVy8X|*_(Q@H z?m+@j0jT3ds4fBr+8snMP)M+^K$Dl`%K7BiFm@MTm}{L@QsyphO+P_BgG)u+!B|}p zwY&luAu0LTsD*Ve`yj4i$9!C8-8wtCd|K2<jAWYRbf!JebSH_RK1FcSDPPa972i1X zxWm?j!+E%(uV&I&BGv24Mkn_~XVtodRkrpOpXT>BA}k`+=CHg%maB8Wq#sxoc{G3A zGIAgr?Dl?#D<L$Y>X7TR!lOE1H!P5h6Qj@YBHk63{%rPZ4@uEt!pu}G+V-z26y2d3 zZ=Fgw>&p}v79RdsAMx9tY?nQxsIS7Bj*dmyAGv>{Lx_1q1|^2XVDb`Rd}n6YlJF~E zj~72d@`V+S7hYCtwZ~B7A1YR6celdOx>D4Us`@Gy10ln{kuyPb!<_PBl#);Y6nMb^ zEuU_P?%?M>h)?p04*d=;Sd?wN;NF*&8tB-+-~J31(3r;cHHVX2zi!4}Dj7ewudv>i z4euzmWJaPVlbN>nvwt6eja`T8jf+VCDV>CkECZ~G#ZWfRd<Lg4{^JYlO$n}!c!$Xj z_OF)WfMu#{!BRt~tWBwl1OWBYj`(ThjfZ+=va?jCGjtm5aTlOCrEX{U$-t4;%fyNi z#S1KZ*d(P${i(sq5TvpxS=gvanGV+q_U~Wf{pY72?(Cyz!>bq6GZ^VA>FR9?iDCBR z{`o1_VonOlG)EHI!taWW%yr|rmG&3K)g_wNxzEs0`6Q{IFm_CE;0#fzk6@lGE#&ML zBc|#<P15_lKsz}^E@X=WEv+sbk>Gqtr+saM(tJ=YP>)(MEaukkO>?KMGzd`d8BxBp zb#<;Q@HhD^dTMm+clzVi=?m})Z>;m{2=Q4ES^vZlZaSZ~|C^*!J%t-xgYo-mUgc)q z{+ZL|P}+zEa`rcF2^0#3_&F#b#!W5OAkjoMhQU2LB^_UbKm;I;TSf@ICswfoz^_Nh z9<n<!5rvxE|2}-Ht`gFX+Zc>~szmiRqISPhWc|5btnxQ;^XGhu#n%w3Oj)I*TXJ}6 zi3UEh2lTm63nex7aA(-sU<;fS1XdR0e58CK_xyAD#uw@t<l7bM)vRzUuh!GXl3#8` z+LoHVS5MDL;dty_@AtbORcbgWckNEjwLWYL{2TVX$!)eZz|nl^MsZ0)<G<c#Xcr9< z^IrRou>HHk>mID2=obu{up7|5Jrw{p-T?u!?c+qa-+^{*LMjs_hrox)`Pu8XyQmKB zzu_z6KTo6YX&?~zc@!2u(EO{i;|oeK6otPl*tZk3?)YG`<)Oo+=@Uvw6hf;Qr{3Yu ziH4fY6I{@om86`@f<et;p6wutzx`*2pZ)&V4mUXmlY(VA#u?h$T3q?!>w85nIxUqT zzIT1Mc+(c*btvJs`B~DGLC?rKnY4Z)oa%uU#4(P6mPX!#Mh5Y7bA|F_(DJ0c*h&y0 zN}I7TTaoG>v5>g1GtMh@0;UY6ET=0n(5eFnV=MwJdbHL%^-g;D3+H~n8n=v1=fZNa zKmPIwY7A2~S~A&Oso?9>F1R4&ou<EE)u*NiBloR4j0RV9*#bWbdz^1PIx0q+1`ZD6 z0DN<qk<>8#+pT=hLmL2WV4wWQztj)pI1yw+@3hC~E%uoE#1HA`DX0drN|8<O5*w4o zhq$!vN03t#Z5!7d1j{6-Bz|Us`CF1MN}^Ye2$aEcprVnV`sWmRrej^J_f-hOIvK<n zX%Iu=L3)0GUri`l*ANqrC?)LSNK|(i=#FREYzb+_RdQQylIUvUD7r}(t9G&R`#ll< zPXKKQlJ}A%X%z#)jyc6e>P3il%tlR-6)p-woeL8d$iKDOQ)@KMw)B*MT7z2m)f%SD zL9Icp=T5DwgOqy2KTWEuS!wZG;pcU0{j_#@?HhNu6`m={)!$j9?z+|FxRzcAPpBrr zYp0DujA0zCbDWzX<aj-<mL{~LX%gBg79hqT#)TNegf@sVi1FNs@ml@vZY@1iNq`GY zCNnBl)6F37FC{CPHXgV(GT`3N5O$*Z(|7!X+tW4tME%x>>ehV7!?83#D7Dnk=!Fl& zN5-i&Dh<sU4l2sjkIB?`MM)-Y5npDvfP!iEnxP0LxB&`^%U!_)H$VYE!Q4SXr&j4+ z!&Hsy{(CT7$Dg!M{D{5Rw^KEpn!6s2y0>y5;5xXCtNPQ{2EA_|blO>a%D?Z+SY>BS z3n~!tLCbFR9m&9C8tO|UdGIm)BuwyhLfn0v^+MmLNnj%yve-f2`}z*^<)H7N?{lZ` zK0PwEdV8?t3qqeG?0rM8Zy<NVeB<ofLgV;x@2YiiyzsNq$kFdIlVgS32(2vjQX+~Z zvxaMCV;Yu6Pf?^e{jG`SYC>{bso6rY(Nu~}L9s!x3&n;xb5Lwh?736yi_|f1bRVjf zp+YlMt52Ige{Jyf-Tc3mpB?jm8TuXeF75ix7W($RwfW=XkY&!UziWG}j%&q{FbsF4 zJOm|;XVfwp33DiF6ATrW1xm;3`1`hBbUS_K(}=l*Id>5AzL>+DJBT@m`TQ*+kBw%C z{jfR}^m9hvg`JNLzQe8|HxJc?WoZv@8dtkHgYOcH<Hz2NGR$KdAq&nZag-_~tTzEs zjWG(?k>G-xu;*%-)t*|5kC%L5OIm4AYf$Un<*s2e9Ml@rdj8b9-VCPGz8=W+tP<q* z>&{eHlIgB4JJWrL%c`ps{$*(T!?)Y}HDlJy;n_oN<^1A9S#iJXR&EV=j|KG_bHcd9 z)~Kkti%O$tQ524)zl;r$O<;jCvjq&0X<$IW%sIe79|mCN9AE%oVE)#w)vqeGbXi}2 z=;44KTEf<>*V~_1?0v}il4&&3!CE^#->v2JVH}+eAAWt;u3l@^t099A`10nrUzPjL z-qDX`hf(u`<={W$=vX+gkrJo4ah4;^ln#U<!y*dSN<$MRSW2|?6X4(-!!IPDKfIWZ zswE78{i*2ymGw2<SL(Cb0R2%i7@46LM?3YIk%9a-CQD39AcLvMpx0gh=lPX?ozgQN z3<`;VxjIPvKT+bh>h-H?_-pODS-%T^ZHH>3yEg7Lq<6O&n*Ud%%*i)lfV1<J)ANn> zkEs4iqfzY*rCZCd9C)ptL!<E5ZuSq9ZLzLW-?Bv03%-OYS$?^r(F?q}&obej&}6hO zPWP?;((D}_Js2yY>KEJ1im5JMo0_kNFIZwlWjBrfUzky;7-bS^tf}Hua`G>Xk&ql? z#)w8-FswQI7p53VGJ5r@sa<^uwZ*N~(Ia;u*>tT%SFNNwTdepm)EghV-Jhn$C63p6 zQOESpAc+u2i4c$;y-IjYpG)`CU3}XMyqr+$lG38-wCjt_P_3IcRfB4syTztSok4T4 zeccSkx4y7<uJS81>x-`5czWmfRil&c>n>r&qU|IUXr`0z8ueBskw>!LE5!d!<2zHS zEj~Xri#DV;D>>aoiRXuLv`O)04t;urzWH!!^~HO&*r`1i5ehyzlLkq5m3HOo<q01> zKS5qIofa$Aw@$W_7}!v4_bh(lG6Q3OIkLOHvw3Fx!~J*Mz+*7>f9}m2i_wx9W|c7^ z65*8aKDflwo(tbC5``2sIz$vF)K>ltXL=d1vNZHYk(k8S&rE~#l9Et43Zid)#{LD2 z4U7$p{f}krB$~Bf<Jg*h5?enTeplJwajQ&_-#PV<A}|)5aY6|pN^4FyL;u2EDpoQ* z4C$FeTC#-3?;x<Rw4V7crdK^Z-K{VC=BeIkA8!5YtdV%DaCx)dJWFrDacCZwlkEE_ zoj#VuhG}1anfMtGHiJsPUNSAy{9mBq9$chr{_G^VSaj)2Oz+%cyM7yLUvT;^kxC<$ z)EjH1l@MBI@HAF<YMvfpW{Ps4SsymPUd11mt6R5~!^54GTxg!*D>asFy%Uidq8N7R z?<DY?^ALhSuFJRSy)o1?!pwNK&2Xa6v}L(vismsb#!EseU|L|>-sLLmz_kB6Oj~a> zfN3FaT*|ii4ci_aA4A+2;>L3pH&%bUxN-IJb|ZYhJ-lky#E;9{V~897*W<?c!*OHr zZ$_4=gnEX+*nP%6KRy{|?8DCP=?od0k|jP|UlSM`;>N((e>85)A#MzD<9~SEnDNxt z<L;cf#0jnl6*MbuO#gV?Sda{8zIpgnA((Hn3f*pBEIdKncoNg{DdNU7jSB?Cje%+V zOnU=N3rq`4`@hSy444*}_8-o)L=Y~iPFPFR_lPj1Jd0_`e<v4WtGRvt*!+I+>-1Zv zb8PQ{X(ur)n}TVHUed7d3^470X@3FJLM{X_?L4lbgIoy6g@845u!atDAz%$1F!tv$ z_RTslHZV3Y_Wx|$nCnksYls^|+;~QDW64slP8s70CHzU>D$QF$hyHljnB_3@s`_y+ zNT@D6EjO#Ny$WID2}sNJhr`C=-z4>!98w_wY5PbE>AirofVBVQQtNkJRjY=j%38JF z@r8yIyPf?(q8%w;dfe>tNf=Vym7}x0O1mr|wX*#Ec&%USwA|;V$A%jf=khZ%uwq&5 zx#oAdYLAX@YxNzGUzPR0cw3ps5fLXr3QwdX%;FfG6i5b3B3ITLDor578at`#`y?Ow iySM2NRC!hZruyFqFJU18H~yD#<NpKY3=tXyITHX-Z$D!I literal 0 HcmV?d00001 diff --git a/x-pack/test/siem_cypress/es_archives/closed_signals/mappings.json b/x-pack/test/siem_cypress/es_archives/closed_signals/mappings.json new file mode 100644 index 0000000000000..94d89ed55dd8a --- /dev/null +++ b/x-pack/test/siem_cypress/es_archives/closed_signals/mappings.json @@ -0,0 +1,7605 @@ +{ + "type": "index", + "value": { + "aliases": { + ".siem-signals-default": { + "is_write_index": true + } + }, + "index": ".siem-signals-default-000001", + "mappings": { + "dynamic": "false", + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "client": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "availability_zone": { + "ignore_above": 1024, + "type": "keyword" + }, + "instance": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "machine": { + "properties": { + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "container": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "runtime": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dns": { + "properties": { + "answers": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "header_flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "question": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "subdomain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resolved_ip": { + "type": "ip" + }, + "response_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "message": { + "norms": false, + "type": "text" + }, + "stack_trace": { + "doc_values": false, + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingested": { + "type": "date" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "doc_values": false, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "outcome": { + "ignore_above": 1024, + "type": "keyword" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "file": { + "properties": { + "accessed": { + "type": "date" + }, + "attributes": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "ctime": { + "type": "date" + }, + "device": { + "ignore_above": 1024, + "type": "keyword" + }, + "directory": { + "ignore_above": 1024, + "type": "keyword" + }, + "drive_letter": { + "ignore_above": 1, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "inode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mtime": { + "type": "date" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "owner": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "target_path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "referrer": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "status_code": { + "type": "long" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "logger": { + "ignore_above": 1024, + "type": "keyword" + }, + "origin": { + "properties": { + "file": { + "properties": { + "line": { + "type": "integer" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "original": { + "doc_values": false, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "syslog": { + "properties": { + "facility": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "priority": { + "type": "long" + }, + "severity": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "message": { + "norms": false, + "type": "text" + }, + "network": { + "properties": { + "application": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "community_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "direction": { + "ignore_above": 1024, + "type": "keyword" + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "packets": { + "type": "long" + }, + "protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "transport": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "observer": { + "properties": { + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "organization": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "package": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "build_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "checksum": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "install_scope": { + "ignore_above": 1024, + "type": "keyword" + }, + "installed": { + "type": "date" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "parent": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registry": { + "properties": { + "data": { + "properties": { + "bytes": { + "ignore_above": 1024, + "type": "keyword" + }, + "strings": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hive": { + "ignore_above": 1024, + "type": "keyword" + }, + "key": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "related": { + "properties": { + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "user": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "ruleset": { + "ignore_above": 1024, + "type": "keyword" + }, + "uuid": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "signal": { + "properties": { + "ancestors": { + "properties": { + "depth": { + "type": "long" + }, + "id": { + "type": "keyword" + }, + "rule": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "original_event": { + "properties": { + "action": { + "type": "keyword" + }, + "category": { + "type": "keyword" + }, + "code": { + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "kind": { + "type": "keyword" + }, + "module": { + "type": "keyword" + }, + "original": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "outcome": { + "type": "keyword" + }, + "provider": { + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "original_time": { + "type": "date" + }, + "parent": { + "properties": { + "depth": { + "type": "long" + }, + "id": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "rule": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "created_at": { + "type": "date" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "enabled": { + "type": "keyword" + }, + "false_positives": { + "type": "keyword" + }, + "filters": { + "type": "object" + }, + "from": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "immutable": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "interval": { + "type": "keyword" + }, + "language": { + "type": "keyword" + }, + "max_signals": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "note": { + "type": "text" + }, + "output_index": { + "type": "keyword" + }, + "query": { + "type": "keyword" + }, + "references": { + "type": "keyword" + }, + "risk_score": { + "type": "keyword" + }, + "rule_id": { + "type": "keyword" + }, + "saved_id": { + "type": "keyword" + }, + "severity": { + "type": "keyword" + }, + "size": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "type": "keyword" + }, + "tactic": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "reference": { + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "reference": { + "type": "keyword" + } + } + } + } + }, + "timeline_id": { + "type": "keyword" + }, + "timeline_title": { + "type": "keyword" + }, + "to": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "status": { + "type": "keyword" + } + } + }, + "source": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "tags": { + "ignore_above": 1024, + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "ignore_above": 1024, + "type": "keyword" + }, + "tactic": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "tls": { + "properties": { + "cipher": { + "ignore_above": 1024, + "type": "keyword" + }, + "client": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_ciphers": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "curve": { + "ignore_above": 1024, + "type": "keyword" + }, + "established": { + "type": "boolean" + }, + "next_protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "resumed": { + "type": "boolean" + }, + "server": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3s": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + }, + "version_protocol": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "trace": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "transaction": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "url": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "fragment": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "password": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "scheme": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "username": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vulnerability": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "classification": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "enumeration": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "report_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "scanner": { + "properties": { + "vendor": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "score": { + "properties": { + "base": { + "type": "float" + }, + "environmental": { + "type": "float" + }, + "temporal": { + "type": "float" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "severity": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "lifecycle": { + "name": ".siem-signals-default", + "rollover_alias": ".siem-signals-default" + }, + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} + +{ + "type": "index", + "value": { + "aliases": { + "auditbeat-7.6.0": { + "is_write_index": true + } + }, + "index": "auditbeat-7.6.0-2020.03.11-000001", + "mappings": { + "_meta": { + "beat": "auditbeat", + "version": "7.6.0" + }, + "date_detection": false, + "dynamic_templates": [ + { + "labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "labels.*" + } + }, + { + "container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "container.labels.*" + } + }, + { + "dns.answers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "dns.answers.*" + } + }, + { + "log.syslog": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "log.syslog.*" + } + }, + { + "fields": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "fields.*" + } + }, + { + "docker.container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "docker.container.labels.*" + } + }, + { + "kubernetes.labels.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.labels.*" + } + }, + { + "kubernetes.annotations.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.annotations.*" + } + }, + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "auditd": { + "properties": { + "data": { + "properties": { + "a0": { + "ignore_above": 1024, + "type": "keyword" + }, + "a1": { + "ignore_above": 1024, + "type": "keyword" + }, + "a2": { + "ignore_above": 1024, + "type": "keyword" + }, + "a3": { + "ignore_above": 1024, + "type": "keyword" + }, + "a[0-3]": { + "ignore_above": 1024, + "type": "keyword" + }, + "acct": { + "ignore_above": 1024, + "type": "keyword" + }, + "acl": { + "ignore_above": 1024, + "type": "keyword" + }, + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "added": { + "ignore_above": 1024, + "type": "keyword" + }, + "addr": { + "ignore_above": 1024, + "type": "keyword" + }, + "apparmor": { + "ignore_above": 1024, + "type": "keyword" + }, + "arch": { + "ignore_above": 1024, + "type": "keyword" + }, + "argc": { + "ignore_above": 1024, + "type": "keyword" + }, + "audit_backlog_limit": { + "ignore_above": 1024, + "type": "keyword" + }, + "audit_backlog_wait_time": { + "ignore_above": 1024, + "type": "keyword" + }, + "audit_enabled": { + "ignore_above": 1024, + "type": "keyword" + }, + "audit_failure": { + "ignore_above": 1024, + "type": "keyword" + }, + "banners": { + "ignore_above": 1024, + "type": "keyword" + }, + "bool": { + "ignore_above": 1024, + "type": "keyword" + }, + "bus": { + "ignore_above": 1024, + "type": "keyword" + }, + "cap_fe": { + "ignore_above": 1024, + "type": "keyword" + }, + "cap_fi": { + "ignore_above": 1024, + "type": "keyword" + }, + "cap_fp": { + "ignore_above": 1024, + "type": "keyword" + }, + "cap_fver": { + "ignore_above": 1024, + "type": "keyword" + }, + "cap_pe": { + "ignore_above": 1024, + "type": "keyword" + }, + "cap_pi": { + "ignore_above": 1024, + "type": "keyword" + }, + "cap_pp": { + "ignore_above": 1024, + "type": "keyword" + }, + "capability": { + "ignore_above": 1024, + "type": "keyword" + }, + "cgroup": { + "ignore_above": 1024, + "type": "keyword" + }, + "changed": { + "ignore_above": 1024, + "type": "keyword" + }, + "cipher": { + "ignore_above": 1024, + "type": "keyword" + }, + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "cmd": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "compat": { + "ignore_above": 1024, + "type": "keyword" + }, + "daddr": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "default-context": { + "ignore_above": 1024, + "type": "keyword" + }, + "device": { + "ignore_above": 1024, + "type": "keyword" + }, + "dir": { + "ignore_above": 1024, + "type": "keyword" + }, + "direction": { + "ignore_above": 1024, + "type": "keyword" + }, + "dmac": { + "ignore_above": 1024, + "type": "keyword" + }, + "dport": { + "ignore_above": 1024, + "type": "keyword" + }, + "enforcing": { + "ignore_above": 1024, + "type": "keyword" + }, + "entries": { + "ignore_above": 1024, + "type": "keyword" + }, + "exit": { + "ignore_above": 1024, + "type": "keyword" + }, + "fam": { + "ignore_above": 1024, + "type": "keyword" + }, + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "fd": { + "ignore_above": 1024, + "type": "keyword" + }, + "fe": { + "ignore_above": 1024, + "type": "keyword" + }, + "feature": { + "ignore_above": 1024, + "type": "keyword" + }, + "fi": { + "ignore_above": 1024, + "type": "keyword" + }, + "file": { + "ignore_above": 1024, + "type": "keyword" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "format": { + "ignore_above": 1024, + "type": "keyword" + }, + "fp": { + "ignore_above": 1024, + "type": "keyword" + }, + "fver": { + "ignore_above": 1024, + "type": "keyword" + }, + "grantors": { + "ignore_above": 1024, + "type": "keyword" + }, + "grp": { + "ignore_above": 1024, + "type": "keyword" + }, + "hook": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "icmp_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "igid": { + "ignore_above": 1024, + "type": "keyword" + }, + "img-ctx": { + "ignore_above": 1024, + "type": "keyword" + }, + "inif": { + "ignore_above": 1024, + "type": "keyword" + }, + "ino": { + "ignore_above": 1024, + "type": "keyword" + }, + "inode_gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "inode_uid": { + "ignore_above": 1024, + "type": "keyword" + }, + "invalid_context": { + "ignore_above": 1024, + "type": "keyword" + }, + "ioctlcmd": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "ignore_above": 1024, + "type": "keyword" + }, + "ipid": { + "ignore_above": 1024, + "type": "keyword" + }, + "ipx-net": { + "ignore_above": 1024, + "type": "keyword" + }, + "items": { + "ignore_above": 1024, + "type": "keyword" + }, + "iuid": { + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "ksize": { + "ignore_above": 1024, + "type": "keyword" + }, + "laddr": { + "ignore_above": 1024, + "type": "keyword" + }, + "len": { + "ignore_above": 1024, + "type": "keyword" + }, + "list": { + "ignore_above": 1024, + "type": "keyword" + }, + "lport": { + "ignore_above": 1024, + "type": "keyword" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "macproto": { + "ignore_above": 1024, + "type": "keyword" + }, + "maj": { + "ignore_above": 1024, + "type": "keyword" + }, + "major": { + "ignore_above": 1024, + "type": "keyword" + }, + "minor": { + "ignore_above": 1024, + "type": "keyword" + }, + "model": { + "ignore_above": 1024, + "type": "keyword" + }, + "msg": { + "ignore_above": 1024, + "type": "keyword" + }, + "nargs": { + "ignore_above": 1024, + "type": "keyword" + }, + "net": { + "ignore_above": 1024, + "type": "keyword" + }, + "new": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-chardev": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-disk": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-enabled": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-fs": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-level": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-log_passwd": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-mem": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-net": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-range": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-rng": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-role": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-seuser": { + "ignore_above": 1024, + "type": "keyword" + }, + "new-vcpu": { + "ignore_above": 1024, + "type": "keyword" + }, + "new_gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "new_lock": { + "ignore_above": 1024, + "type": "keyword" + }, + "new_pe": { + "ignore_above": 1024, + "type": "keyword" + }, + "new_pi": { + "ignore_above": 1024, + "type": "keyword" + }, + "new_pp": { + "ignore_above": 1024, + "type": "keyword" + }, + "nlnk-fam": { + "ignore_above": 1024, + "type": "keyword" + }, + "nlnk-grp": { + "ignore_above": 1024, + "type": "keyword" + }, + "nlnk-pid": { + "ignore_above": 1024, + "type": "keyword" + }, + "oauid": { + "ignore_above": 1024, + "type": "keyword" + }, + "obj": { + "ignore_above": 1024, + "type": "keyword" + }, + "obj_gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "obj_uid": { + "ignore_above": 1024, + "type": "keyword" + }, + "ocomm": { + "ignore_above": 1024, + "type": "keyword" + }, + "oflag": { + "ignore_above": 1024, + "type": "keyword" + }, + "old": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-auid": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-chardev": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-disk": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-enabled": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-fs": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-level": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-log_passwd": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-mem": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-net": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-range": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-rng": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-role": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-ses": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-seuser": { + "ignore_above": 1024, + "type": "keyword" + }, + "old-vcpu": { + "ignore_above": 1024, + "type": "keyword" + }, + "old_enforcing": { + "ignore_above": 1024, + "type": "keyword" + }, + "old_lock": { + "ignore_above": 1024, + "type": "keyword" + }, + "old_pe": { + "ignore_above": 1024, + "type": "keyword" + }, + "old_pi": { + "ignore_above": 1024, + "type": "keyword" + }, + "old_pp": { + "ignore_above": 1024, + "type": "keyword" + }, + "old_prom": { + "ignore_above": 1024, + "type": "keyword" + }, + "old_val": { + "ignore_above": 1024, + "type": "keyword" + }, + "op": { + "ignore_above": 1024, + "type": "keyword" + }, + "opid": { + "ignore_above": 1024, + "type": "keyword" + }, + "oses": { + "ignore_above": 1024, + "type": "keyword" + }, + "outif": { + "ignore_above": 1024, + "type": "keyword" + }, + "parent": { + "ignore_above": 1024, + "type": "keyword" + }, + "per": { + "ignore_above": 1024, + "type": "keyword" + }, + "perm": { + "ignore_above": 1024, + "type": "keyword" + }, + "perm_mask": { + "ignore_above": 1024, + "type": "keyword" + }, + "permissive": { + "ignore_above": 1024, + "type": "keyword" + }, + "pfs": { + "ignore_above": 1024, + "type": "keyword" + }, + "printer": { + "ignore_above": 1024, + "type": "keyword" + }, + "prom": { + "ignore_above": 1024, + "type": "keyword" + }, + "proto": { + "ignore_above": 1024, + "type": "keyword" + }, + "qbytes": { + "ignore_above": 1024, + "type": "keyword" + }, + "range": { + "ignore_above": 1024, + "type": "keyword" + }, + "reason": { + "ignore_above": 1024, + "type": "keyword" + }, + "removed": { + "ignore_above": 1024, + "type": "keyword" + }, + "res": { + "ignore_above": 1024, + "type": "keyword" + }, + "resrc": { + "ignore_above": 1024, + "type": "keyword" + }, + "rport": { + "ignore_above": 1024, + "type": "keyword" + }, + "sauid": { + "ignore_above": 1024, + "type": "keyword" + }, + "scontext": { + "ignore_above": 1024, + "type": "keyword" + }, + "selected-context": { + "ignore_above": 1024, + "type": "keyword" + }, + "seperm": { + "ignore_above": 1024, + "type": "keyword" + }, + "seperms": { + "ignore_above": 1024, + "type": "keyword" + }, + "seqno": { + "ignore_above": 1024, + "type": "keyword" + }, + "seresult": { + "ignore_above": 1024, + "type": "keyword" + }, + "ses": { + "ignore_above": 1024, + "type": "keyword" + }, + "seuser": { + "ignore_above": 1024, + "type": "keyword" + }, + "sig": { + "ignore_above": 1024, + "type": "keyword" + }, + "sigev_signo": { + "ignore_above": 1024, + "type": "keyword" + }, + "smac": { + "ignore_above": 1024, + "type": "keyword" + }, + "socket": { + "properties": { + "addr": { + "ignore_above": 1024, + "type": "keyword" + }, + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "ignore_above": 1024, + "type": "keyword" + }, + "saddr": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "spid": { + "ignore_above": 1024, + "type": "keyword" + }, + "sport": { + "ignore_above": 1024, + "type": "keyword" + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "subj": { + "ignore_above": 1024, + "type": "keyword" + }, + "success": { + "ignore_above": 1024, + "type": "keyword" + }, + "syscall": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "tclass": { + "ignore_above": 1024, + "type": "keyword" + }, + "tcontext": { + "ignore_above": 1024, + "type": "keyword" + }, + "terminal": { + "ignore_above": 1024, + "type": "keyword" + }, + "tty": { + "ignore_above": 1024, + "type": "keyword" + }, + "unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "uri": { + "ignore_above": 1024, + "type": "keyword" + }, + "uuid": { + "ignore_above": 1024, + "type": "keyword" + }, + "val": { + "ignore_above": 1024, + "type": "keyword" + }, + "ver": { + "ignore_above": 1024, + "type": "keyword" + }, + "virt": { + "ignore_above": 1024, + "type": "keyword" + }, + "vm": { + "ignore_above": 1024, + "type": "keyword" + }, + "vm-ctx": { + "ignore_above": 1024, + "type": "keyword" + }, + "vm-pid": { + "ignore_above": 1024, + "type": "keyword" + }, + "watch": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "message_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "paths": { + "properties": { + "dev": { + "ignore_above": 1024, + "type": "keyword" + }, + "inode": { + "ignore_above": 1024, + "type": "keyword" + }, + "item": { + "ignore_above": 1024, + "type": "keyword" + }, + "mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "nametype": { + "ignore_above": 1024, + "type": "keyword" + }, + "obj_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "obj_level": { + "ignore_above": 1024, + "type": "keyword" + }, + "obj_role": { + "ignore_above": 1024, + "type": "keyword" + }, + "obj_user": { + "ignore_above": 1024, + "type": "keyword" + }, + "objtype": { + "ignore_above": 1024, + "type": "keyword" + }, + "ogid": { + "ignore_above": 1024, + "type": "keyword" + }, + "ouid": { + "ignore_above": 1024, + "type": "keyword" + }, + "rdev": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "result": { + "ignore_above": 1024, + "type": "keyword" + }, + "sequence": { + "type": "long" + }, + "session": { + "ignore_above": 1024, + "type": "keyword" + }, + "summary": { + "properties": { + "actor": { + "properties": { + "primary": { + "ignore_above": 1024, + "type": "keyword" + }, + "secondary": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "how": { + "ignore_above": 1024, + "type": "keyword" + }, + "object": { + "properties": { + "primary": { + "ignore_above": 1024, + "type": "keyword" + }, + "secondary": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "client": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "availability_zone": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "instance": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "machine": { + "properties": { + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "project": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "container": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "runtime": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dns": { + "properties": { + "answers": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "header_flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "question": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "subdomain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resolved_ip": { + "type": "ip" + }, + "response_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "docker": { + "properties": { + "container": { + "properties": { + "labels": { + "type": "object" + } + } + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "message": { + "norms": false, + "type": "text" + }, + "stack_trace": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingested": { + "type": "date" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "origin": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "outcome": { + "ignore_above": 1024, + "type": "keyword" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "fields": { + "type": "object" + }, + "file": { + "properties": { + "accessed": { + "type": "date" + }, + "attributes": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "ctime": { + "type": "date" + }, + "device": { + "ignore_above": 1024, + "type": "keyword" + }, + "directory": { + "ignore_above": 1024, + "type": "keyword" + }, + "drive_letter": { + "ignore_above": 1, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "inode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mtime": { + "type": "date" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "origin": { + "fields": { + "raw": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "owner": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "selinux": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "role": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "setgid": { + "type": "boolean" + }, + "setuid": { + "type": "boolean" + }, + "size": { + "type": "long" + }, + "target_path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "geoip": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "properties": { + "blake2b_256": { + "ignore_above": 1024, + "type": "keyword" + }, + "blake2b_384": { + "ignore_above": 1024, + "type": "keyword" + }, + "blake2b_512": { + "ignore_above": 1024, + "type": "keyword" + }, + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha224": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha384": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha3_224": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha3_256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha3_384": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha3_512": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512_224": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512_256": { + "ignore_above": 1024, + "type": "keyword" + }, + "xxh64": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "containerized": { + "type": "boolean" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "build": { + "ignore_above": 1024, + "type": "keyword" + }, + "codename": { + "ignore_above": 1024, + "type": "keyword" + }, + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "referrer": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "status_code": { + "type": "long" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "jolokia": { + "properties": { + "agent": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "secured": { + "type": "boolean" + }, + "server": { + "properties": { + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "kubernetes": { + "properties": { + "annotations": { + "properties": { + "*": { + "type": "object" + } + } + }, + "container": { + "properties": { + "image": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "deployment": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "properties": { + "*": { + "type": "object" + } + } + }, + "namespace": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pod": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "replicaset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "statefulset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "logger": { + "ignore_above": 1024, + "type": "keyword" + }, + "origin": { + "properties": { + "file": { + "properties": { + "line": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "syslog": { + "properties": { + "facility": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "priority": { + "type": "long" + }, + "severity": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "message": { + "norms": false, + "type": "text" + }, + "network": { + "properties": { + "application": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "community_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "direction": { + "ignore_above": 1024, + "type": "keyword" + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "packets": { + "type": "long" + }, + "protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "transport": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "observer": { + "properties": { + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "organization": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "package": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "build_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "checksum": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "install_scope": { + "ignore_above": 1024, + "type": "keyword" + }, + "installed": { + "type": "date" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "entity_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "blake2b_256": { + "ignore_above": 1024, + "type": "keyword" + }, + "blake2b_384": { + "ignore_above": 1024, + "type": "keyword" + }, + "blake2b_512": { + "ignore_above": 1024, + "type": "keyword" + }, + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha224": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha384": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha3_224": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha3_256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha3_384": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha3_512": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512_224": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512_256": { + "ignore_above": 1024, + "type": "keyword" + }, + "xxh64": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "parent": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registry": { + "properties": { + "data": { + "properties": { + "bytes": { + "ignore_above": 1024, + "type": "keyword" + }, + "strings": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hive": { + "ignore_above": 1024, + "type": "keyword" + }, + "key": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "related": { + "properties": { + "ip": { + "type": "ip" + }, + "user": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "ruleset": { + "ignore_above": 1024, + "type": "keyword" + }, + "uuid": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "socket": { + "properties": { + "entity_id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "source": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "system": { + "properties": { + "audit": { + "properties": { + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "boottime": { + "type": "date" + }, + "containerized": { + "type": "boolean" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "codename": { + "ignore_above": 1024, + "type": "keyword" + }, + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "timezone": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "offset": { + "properties": { + "sec": { + "type": "long" + } + } + } + } + }, + "uptime": { + "type": "long" + } + } + }, + "package": { + "properties": { + "arch": { + "ignore_above": 1024, + "type": "keyword" + }, + "entity_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "installtime": { + "type": "date" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "release": { + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "summary": { + "ignore_above": 1024, + "type": "keyword" + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "dir": { + "ignore_above": 1024, + "type": "keyword" + }, + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "type": "object" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "password": { + "properties": { + "last_changed": { + "type": "date" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "shell": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + }, + "user_information": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "tags": { + "ignore_above": 1024, + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "ignore_above": 1024, + "type": "keyword" + }, + "tactic": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "timeseries": { + "properties": { + "instance": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tls": { + "properties": { + "cipher": { + "ignore_above": 1024, + "type": "keyword" + }, + "client": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_ciphers": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "curve": { + "ignore_above": 1024, + "type": "keyword" + }, + "established": { + "type": "boolean" + }, + "next_protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "resumed": { + "type": "boolean" + }, + "server": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3s": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + }, + "version_protocol": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tracing": { + "properties": { + "trace": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "transaction": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "url": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "fragment": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "password": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "scheme": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "username": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "audit": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "effective": { + "properties": { + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "entity_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "filesystem": { + "properties": { + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "name_map": { + "type": "object" + }, + "saved": { + "properties": { + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "selinux": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "role": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "terminal": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vulnerability": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "classification": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "enumeration": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "report_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "scanner": { + "properties": { + "vendor": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "score": { + "properties": { + "base": { + "type": "float" + }, + "environmental": { + "type": "float" + }, + "temporal": { + "type": "float" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "severity": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "lifecycle": { + "name": "auditbeat", + "rollover_alias": "auditbeat-7.6.0" + }, + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "number_of_replicas": "1", + "number_of_shards": "1", + "query": { + "default_field": [ + "message", + "tags", + "agent.ephemeral_id", + "agent.id", + "agent.name", + "agent.type", + "agent.version", + "as.organization.name", + "client.address", + "client.as.organization.name", + "client.domain", + "client.geo.city_name", + "client.geo.continent_name", + "client.geo.country_iso_code", + "client.geo.country_name", + "client.geo.name", + "client.geo.region_iso_code", + "client.geo.region_name", + "client.mac", + "client.registered_domain", + "client.top_level_domain", + "client.user.domain", + "client.user.email", + "client.user.full_name", + "client.user.group.domain", + "client.user.group.id", + "client.user.group.name", + "client.user.hash", + "client.user.id", + "client.user.name", + "cloud.account.id", + "cloud.availability_zone", + "cloud.instance.id", + "cloud.instance.name", + "cloud.machine.type", + "cloud.provider", + "cloud.region", + "container.id", + "container.image.name", + "container.image.tag", + "container.name", + "container.runtime", + "destination.address", + "destination.as.organization.name", + "destination.domain", + "destination.geo.city_name", + "destination.geo.continent_name", + "destination.geo.country_iso_code", + "destination.geo.country_name", + "destination.geo.name", + "destination.geo.region_iso_code", + "destination.geo.region_name", + "destination.mac", + "destination.registered_domain", + "destination.top_level_domain", + "destination.user.domain", + "destination.user.email", + "destination.user.full_name", + "destination.user.group.domain", + "destination.user.group.id", + "destination.user.group.name", + "destination.user.hash", + "destination.user.id", + "destination.user.name", + "dns.answers.class", + "dns.answers.data", + "dns.answers.name", + "dns.answers.type", + "dns.header_flags", + "dns.id", + "dns.op_code", + "dns.question.class", + "dns.question.name", + "dns.question.registered_domain", + "dns.question.subdomain", + "dns.question.top_level_domain", + "dns.question.type", + "dns.response_code", + "dns.type", + "ecs.version", + "error.code", + "error.id", + "error.message", + "error.stack_trace", + "error.type", + "event.action", + "event.category", + "event.code", + "event.dataset", + "event.hash", + "event.id", + "event.kind", + "event.module", + "event.original", + "event.outcome", + "event.provider", + "event.timezone", + "event.type", + "file.device", + "file.directory", + "file.extension", + "file.gid", + "file.group", + "file.hash.md5", + "file.hash.sha1", + "file.hash.sha256", + "file.hash.sha512", + "file.inode", + "file.mode", + "file.name", + "file.owner", + "file.path", + "file.target_path", + "file.type", + "file.uid", + "geo.city_name", + "geo.continent_name", + "geo.country_iso_code", + "geo.country_name", + "geo.name", + "geo.region_iso_code", + "geo.region_name", + "group.domain", + "group.id", + "group.name", + "hash.md5", + "hash.sha1", + "hash.sha256", + "hash.sha512", + "host.architecture", + "host.geo.city_name", + "host.geo.continent_name", + "host.geo.country_iso_code", + "host.geo.country_name", + "host.geo.name", + "host.geo.region_iso_code", + "host.geo.region_name", + "host.hostname", + "host.id", + "host.mac", + "host.name", + "host.os.family", + "host.os.full", + "host.os.kernel", + "host.os.name", + "host.os.platform", + "host.os.version", + "host.type", + "host.user.domain", + "host.user.email", + "host.user.full_name", + "host.user.group.domain", + "host.user.group.id", + "host.user.group.name", + "host.user.hash", + "host.user.id", + "host.user.name", + "http.request.body.content", + "http.request.method", + "http.request.referrer", + "http.response.body.content", + "http.version", + "log.level", + "log.logger", + "log.origin.file.name", + "log.origin.function", + "log.original", + "log.syslog.facility.name", + "log.syslog.severity.name", + "network.application", + "network.community_id", + "network.direction", + "network.iana_number", + "network.name", + "network.protocol", + "network.transport", + "network.type", + "observer.geo.city_name", + "observer.geo.continent_name", + "observer.geo.country_iso_code", + "observer.geo.country_name", + "observer.geo.name", + "observer.geo.region_iso_code", + "observer.geo.region_name", + "observer.hostname", + "observer.mac", + "observer.name", + "observer.os.family", + "observer.os.full", + "observer.os.kernel", + "observer.os.name", + "observer.os.platform", + "observer.os.version", + "observer.product", + "observer.serial_number", + "observer.type", + "observer.vendor", + "observer.version", + "organization.id", + "organization.name", + "os.family", + "os.full", + "os.kernel", + "os.name", + "os.platform", + "os.version", + "package.architecture", + "package.checksum", + "package.description", + "package.install_scope", + "package.license", + "package.name", + "package.path", + "package.version", + "process.args", + "text", + "process.executable", + "process.hash.md5", + "process.hash.sha1", + "process.hash.sha256", + "process.hash.sha512", + "process.name", + "text", + "text", + "text", + "text", + "text", + "process.thread.name", + "process.title", + "process.working_directory", + "server.address", + "server.as.organization.name", + "server.domain", + "server.geo.city_name", + "server.geo.continent_name", + "server.geo.country_iso_code", + "server.geo.country_name", + "server.geo.name", + "server.geo.region_iso_code", + "server.geo.region_name", + "server.mac", + "server.registered_domain", + "server.top_level_domain", + "server.user.domain", + "server.user.email", + "server.user.full_name", + "server.user.group.domain", + "server.user.group.id", + "server.user.group.name", + "server.user.hash", + "server.user.id", + "server.user.name", + "service.ephemeral_id", + "service.id", + "service.name", + "service.node.name", + "service.state", + "service.type", + "service.version", + "source.address", + "source.as.organization.name", + "source.domain", + "source.geo.city_name", + "source.geo.continent_name", + "source.geo.country_iso_code", + "source.geo.country_name", + "source.geo.name", + "source.geo.region_iso_code", + "source.geo.region_name", + "source.mac", + "source.registered_domain", + "source.top_level_domain", + "source.user.domain", + "source.user.email", + "source.user.full_name", + "source.user.group.domain", + "source.user.group.id", + "source.user.group.name", + "source.user.hash", + "source.user.id", + "source.user.name", + "threat.framework", + "threat.tactic.id", + "threat.tactic.name", + "threat.tactic.reference", + "threat.technique.id", + "threat.technique.name", + "threat.technique.reference", + "tracing.trace.id", + "tracing.transaction.id", + "url.domain", + "url.extension", + "url.fragment", + "url.full", + "url.original", + "url.password", + "url.path", + "url.query", + "url.registered_domain", + "url.scheme", + "url.top_level_domain", + "url.username", + "user.domain", + "user.email", + "user.full_name", + "user.group.domain", + "user.group.id", + "user.group.name", + "user.hash", + "user.id", + "user.name", + "user_agent.device.name", + "user_agent.name", + "text", + "user_agent.original", + "user_agent.os.family", + "user_agent.os.full", + "user_agent.os.kernel", + "user_agent.os.name", + "user_agent.os.platform", + "user_agent.os.version", + "user_agent.version", + "text", + "agent.hostname", + "timeseries.instance", + "cloud.project.id", + "cloud.image.id", + "host.os.build", + "host.os.codename", + "kubernetes.pod.name", + "kubernetes.pod.uid", + "kubernetes.namespace", + "kubernetes.node.name", + "kubernetes.replicaset.name", + "kubernetes.deployment.name", + "kubernetes.statefulset.name", + "kubernetes.container.name", + "kubernetes.container.image", + "jolokia.agent.version", + "jolokia.agent.id", + "jolokia.server.product", + "jolokia.server.version", + "jolokia.server.vendor", + "jolokia.url", + "raw", + "file.origin", + "file.selinux.user", + "file.selinux.role", + "file.selinux.domain", + "file.selinux.level", + "user.audit.id", + "user.audit.name", + "user.effective.id", + "user.effective.name", + "user.effective.group.id", + "user.effective.group.name", + "user.filesystem.id", + "user.filesystem.name", + "user.filesystem.group.id", + "user.filesystem.group.name", + "user.saved.id", + "user.saved.name", + "user.saved.group.id", + "user.saved.group.name", + "user.selinux.user", + "user.selinux.role", + "user.selinux.domain", + "user.selinux.level", + "user.selinux.category", + "source.path", + "destination.path", + "auditd.message_type", + "auditd.session", + "auditd.result", + "auditd.summary.actor.primary", + "auditd.summary.actor.secondary", + "auditd.summary.object.type", + "auditd.summary.object.primary", + "auditd.summary.object.secondary", + "auditd.summary.how", + "auditd.paths.inode", + "auditd.paths.dev", + "auditd.paths.obj_user", + "auditd.paths.obj_role", + "auditd.paths.obj_domain", + "auditd.paths.obj_level", + "auditd.paths.objtype", + "auditd.paths.ouid", + "auditd.paths.rdev", + "auditd.paths.nametype", + "auditd.paths.ogid", + "auditd.paths.item", + "auditd.paths.mode", + "auditd.paths.name", + "auditd.data.action", + "auditd.data.minor", + "auditd.data.acct", + "auditd.data.addr", + "auditd.data.cipher", + "auditd.data.id", + "auditd.data.entries", + "auditd.data.kind", + "auditd.data.ksize", + "auditd.data.spid", + "auditd.data.arch", + "auditd.data.argc", + "auditd.data.major", + "auditd.data.unit", + "auditd.data.table", + "auditd.data.terminal", + "auditd.data.grantors", + "auditd.data.direction", + "auditd.data.op", + "auditd.data.tty", + "auditd.data.syscall", + "auditd.data.data", + "auditd.data.family", + "auditd.data.mac", + "auditd.data.pfs", + "auditd.data.items", + "auditd.data.a0", + "auditd.data.a1", + "auditd.data.a2", + "auditd.data.a3", + "auditd.data.hostname", + "auditd.data.lport", + "auditd.data.rport", + "auditd.data.exit", + "auditd.data.fp", + "auditd.data.laddr", + "auditd.data.sport", + "auditd.data.capability", + "auditd.data.nargs", + "auditd.data.new-enabled", + "auditd.data.audit_backlog_limit", + "auditd.data.dir", + "auditd.data.cap_pe", + "auditd.data.model", + "auditd.data.new_pp", + "auditd.data.old-enabled", + "auditd.data.oauid", + "auditd.data.old", + "auditd.data.banners", + "auditd.data.feature", + "auditd.data.vm-ctx", + "auditd.data.opid", + "auditd.data.seperms", + "auditd.data.seresult", + "auditd.data.new-rng", + "auditd.data.old-net", + "auditd.data.sigev_signo", + "auditd.data.ino", + "auditd.data.old_enforcing", + "auditd.data.old-vcpu", + "auditd.data.range", + "auditd.data.res", + "auditd.data.added", + "auditd.data.fam", + "auditd.data.nlnk-pid", + "auditd.data.subj", + "auditd.data.a[0-3]", + "auditd.data.cgroup", + "auditd.data.kernel", + "auditd.data.ocomm", + "auditd.data.new-net", + "auditd.data.permissive", + "auditd.data.class", + "auditd.data.compat", + "auditd.data.fi", + "auditd.data.changed", + "auditd.data.msg", + "auditd.data.dport", + "auditd.data.new-seuser", + "auditd.data.invalid_context", + "auditd.data.dmac", + "auditd.data.ipx-net", + "auditd.data.iuid", + "auditd.data.macproto", + "auditd.data.obj", + "auditd.data.ipid", + "auditd.data.new-fs", + "auditd.data.vm-pid", + "auditd.data.cap_pi", + "auditd.data.old-auid", + "auditd.data.oses", + "auditd.data.fd", + "auditd.data.igid", + "auditd.data.new-disk", + "auditd.data.parent", + "auditd.data.len", + "auditd.data.oflag", + "auditd.data.uuid", + "auditd.data.code", + "auditd.data.nlnk-grp", + "auditd.data.cap_fp", + "auditd.data.new-mem", + "auditd.data.seperm", + "auditd.data.enforcing", + "auditd.data.new-chardev", + "auditd.data.old-rng", + "auditd.data.outif", + "auditd.data.cmd", + "auditd.data.hook", + "auditd.data.new-level", + "auditd.data.sauid", + "auditd.data.sig", + "auditd.data.audit_backlog_wait_time", + "auditd.data.printer", + "auditd.data.old-mem", + "auditd.data.perm", + "auditd.data.old_pi", + "auditd.data.state", + "auditd.data.format", + "auditd.data.new_gid", + "auditd.data.tcontext", + "auditd.data.maj", + "auditd.data.watch", + "auditd.data.device", + "auditd.data.grp", + "auditd.data.bool", + "auditd.data.icmp_type", + "auditd.data.new_lock", + "auditd.data.old_prom", + "auditd.data.acl", + "auditd.data.ip", + "auditd.data.new_pi", + "auditd.data.default-context", + "auditd.data.inode_gid", + "auditd.data.new-log_passwd", + "auditd.data.new_pe", + "auditd.data.selected-context", + "auditd.data.cap_fver", + "auditd.data.file", + "auditd.data.net", + "auditd.data.virt", + "auditd.data.cap_pp", + "auditd.data.old-range", + "auditd.data.resrc", + "auditd.data.new-range", + "auditd.data.obj_gid", + "auditd.data.proto", + "auditd.data.old-disk", + "auditd.data.audit_failure", + "auditd.data.inif", + "auditd.data.vm", + "auditd.data.flags", + "auditd.data.nlnk-fam", + "auditd.data.old-fs", + "auditd.data.old-ses", + "auditd.data.seqno", + "auditd.data.fver", + "auditd.data.qbytes", + "auditd.data.seuser", + "auditd.data.cap_fe", + "auditd.data.new-vcpu", + "auditd.data.old-level", + "auditd.data.old_pp", + "auditd.data.daddr", + "auditd.data.old-role", + "auditd.data.ioctlcmd", + "auditd.data.smac", + "auditd.data.apparmor", + "auditd.data.fe", + "auditd.data.perm_mask", + "auditd.data.ses", + "auditd.data.cap_fi", + "auditd.data.obj_uid", + "auditd.data.reason", + "auditd.data.list", + "auditd.data.old_lock", + "auditd.data.bus", + "auditd.data.old_pe", + "auditd.data.new-role", + "auditd.data.prom", + "auditd.data.uri", + "auditd.data.audit_enabled", + "auditd.data.old-log_passwd", + "auditd.data.old-seuser", + "auditd.data.per", + "auditd.data.scontext", + "auditd.data.tclass", + "auditd.data.ver", + "auditd.data.new", + "auditd.data.val", + "auditd.data.img-ctx", + "auditd.data.old-chardev", + "auditd.data.old_val", + "auditd.data.success", + "auditd.data.inode_uid", + "auditd.data.removed", + "auditd.data.socket.port", + "auditd.data.socket.saddr", + "auditd.data.socket.addr", + "auditd.data.socket.family", + "auditd.data.socket.path", + "geoip.continent_name", + "geoip.city_name", + "geoip.region_name", + "geoip.country_iso_code", + "hash.blake2b_256", + "hash.blake2b_384", + "hash.blake2b_512", + "hash.md5", + "hash.sha1", + "hash.sha224", + "hash.sha256", + "hash.sha384", + "hash.sha3_224", + "hash.sha3_256", + "hash.sha3_384", + "hash.sha3_512", + "hash.sha512", + "hash.sha512_224", + "hash.sha512_256", + "hash.xxh64", + "event.origin", + "user.entity_id", + "user.terminal", + "process.entity_id", + "process.hash.blake2b_256", + "process.hash.blake2b_384", + "process.hash.blake2b_512", + "process.hash.sha224", + "process.hash.sha384", + "process.hash.sha3_224", + "process.hash.sha3_256", + "process.hash.sha3_384", + "process.hash.sha3_512", + "process.hash.sha512_224", + "process.hash.sha512_256", + "process.hash.xxh64", + "socket.entity_id", + "system.audit.host.timezone.name", + "system.audit.host.hostname", + "system.audit.host.id", + "system.audit.host.architecture", + "system.audit.host.mac", + "system.audit.host.os.codename", + "system.audit.host.os.platform", + "system.audit.host.os.name", + "system.audit.host.os.family", + "system.audit.host.os.version", + "system.audit.host.os.kernel", + "system.audit.package.entity_id", + "system.audit.package.name", + "system.audit.package.version", + "system.audit.package.release", + "system.audit.package.arch", + "system.audit.package.license", + "system.audit.package.summary", + "system.audit.package.url", + "system.audit.user.name", + "system.audit.user.uid", + "system.audit.user.gid", + "system.audit.user.dir", + "system.audit.user.shell", + "system.audit.user.user_information", + "system.audit.user.password.type", + "fields.*" + ] + }, + "refresh_interval": "5s" + } + } + } +} \ No newline at end of file From c537d453e6d0a5e97fad3976fb3147234c2e4f20 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm <matthias.wilhelm@elastic.co> Date: Mon, 23 Mar 2020 19:14:14 +0100 Subject: [PATCH 027/179] Inline timezoneProvider function, remove ui/vis/lib/timezone (#60475) * Inline getTimezone in discover, vis_type_timeseries, timelion app & vis_type_timelion --- .../kibana/public/discover/kibana_services.ts | 3 --- .../np_ready/angular/directives/histogram.tsx | 15 +++++++++++++-- .../core_plugins/timelion/public/app.js | 7 +++---- .../public/helpers/get_timezone.ts} | 19 +++++++++---------- .../helpers/timelion_request_handler.ts | 4 ++-- .../vis_type_timelion/public/index.ts | 2 ++ .../public/legacy_imports.ts | 3 --- .../get_timezone.ts} | 14 ++++++++++++-- .../public/request_handler.js | 4 ++-- .../visualizations/views/timeseries/index.js | 5 ++--- 10 files changed, 45 insertions(+), 31 deletions(-) rename src/legacy/{ui/public/vis/lib/timezone.js => core_plugins/vis_type_timelion/public/helpers/get_timezone.ts} (70%) rename src/legacy/core_plugins/vis_type_timeseries/public/{legacy_imports.ts => lib/get_timezone.ts} (66%) diff --git a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts index cf76a9355e384..d369eb9679de6 100644 --- a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts @@ -52,9 +52,6 @@ export { angular }; export { wrapInI18nContext } from 'ui/i18n'; import { search } from '../../../../../plugins/data/public'; export const { getRequestInspectorStats, getResponseInspectorStats, tabifyAggResponse } = search; -// @ts-ignore -// @ts-ignore -export { timezoneProvider } from 'ui/vis/lib/timezone'; export { unhashUrl, redirectWhenMissing, diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx index 107c30ec5e688..f788347ac016c 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx @@ -42,9 +42,10 @@ import { } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; +import { IUiSettingsClient } from 'kibana/public'; import { EuiChartThemeType } from '@elastic/eui/dist/eui_charts_theme'; import { Subscription } from 'rxjs'; -import { getServices, timezoneProvider } from '../../../kibana_services'; +import { getServices } from '../../../kibana_services'; export interface DiscoverHistogramProps { chartData: any; @@ -86,6 +87,16 @@ function getIntervalInMs( } } +function getTimezone(uiSettings: IUiSettingsClient) { + if (uiSettings.isDefault('dateFormat:tz')) { + const detectedTimezone = moment.tz.guess(); + if (detectedTimezone) return detectedTimezone; + else return moment().format('Z'); + } else { + return uiSettings.get('dateFormat:tz', 'Browser'); + } +} + export function findMinInterval( xValues: number[], esValue: number, @@ -193,7 +204,7 @@ export class DiscoverHistogram extends Component<DiscoverHistogramProps, Discove public render() { const uiSettings = getServices().uiSettings; - const timeZone = timezoneProvider(uiSettings)(); + const timeZone = getTimezone(uiSettings); const { chartData } = this.props; const { chartsTheme } = this.state; diff --git a/src/legacy/core_plugins/timelion/public/app.js b/src/legacy/core_plugins/timelion/public/app.js index 66d93b4ce9b89..a50f8a2cd3e8d 100644 --- a/src/legacy/core_plugins/timelion/public/app.js +++ b/src/legacy/core_plugins/timelion/public/app.js @@ -24,10 +24,10 @@ import { i18n } from '@kbn/i18n'; import { capabilities } from 'ui/capabilities'; import { docTitle } from 'ui/doc_title'; import { fatalError, toastNotifications } from 'ui/notify'; -import { timezoneProvider } from 'ui/vis/lib/timezone'; import { timefilter } from 'ui/timefilter'; import { npStart } from 'ui/new_platform'; import { getSavedSheetBreadcrumbs, getCreateBreadcrumbs } from './breadcrumbs'; +import { getTimezone } from '../../vis_type_timelion/public'; import 'uiExports/savedObjectTypes'; @@ -115,8 +115,7 @@ app.controller('timelion', function( $timeout, AppState, config, - kbnUrl, - Private + kbnUrl ) { // Keeping this at app scope allows us to keep the current page when the user // switches to say, the timepicker. @@ -127,7 +126,7 @@ app.controller('timelion', function( timefilter.enableTimeRangeSelector(); const savedVisualizations = visualizations.savedVisualizationsLoader; - const timezone = Private(timezoneProvider)(); + const timezone = getTimezone(config); const defaultExpression = '.es(*)'; const savedSheet = $route.current.locals.savedSheet; diff --git a/src/legacy/ui/public/vis/lib/timezone.js b/src/legacy/core_plugins/vis_type_timelion/public/helpers/get_timezone.ts similarity index 70% rename from src/legacy/ui/public/vis/lib/timezone.js rename to src/legacy/core_plugins/vis_type_timelion/public/helpers/get_timezone.ts index a526ca55f9c86..f1e8fc56901e1 100644 --- a/src/legacy/ui/public/vis/lib/timezone.js +++ b/src/legacy/core_plugins/vis_type_timelion/public/helpers/get_timezone.ts @@ -18,15 +18,14 @@ */ import moment from 'moment-timezone'; +import { IUiSettingsClient } from 'kibana/public'; -export function timezoneProvider(config) { - return function() { - if (config.isDefault('dateFormat:tz')) { - const detectedTimezone = moment.tz.guess(); - if (detectedTimezone) return detectedTimezone; - else return moment().format('Z'); - } else { - return config.get('dateFormat:tz', 'Browser'); - } - }; +export function getTimezone(config: IUiSettingsClient) { + if (config.isDefault('dateFormat:tz')) { + const detectedTimezone = moment.tz.guess(); + if (detectedTimezone) return detectedTimezone; + else return moment().format('Z'); + } else { + return config.get('dateFormat:tz', 'Browser'); + } } diff --git a/src/legacy/core_plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts b/src/legacy/core_plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts index 603c911438f2a..47bfed6340e93 100644 --- a/src/legacy/core_plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts +++ b/src/legacy/core_plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts @@ -21,8 +21,8 @@ import { i18n } from '@kbn/i18n'; import { KIBANA_CONTEXT_NAME } from 'src/plugins/expressions/public'; import { VisParams } from 'src/legacy/core_plugins/visualizations/public'; import { TimeRange, Filter, esQuery, Query } from '../../../../../plugins/data/public'; -import { timezoneProvider } from '../legacy_imports'; import { TimelionVisDependencies } from '../plugin'; +import { getTimezone } from './get_timezone'; interface Stats { cacheCount: number; @@ -66,7 +66,7 @@ export function getTimelionRequestHandler({ http, timefilter, }: TimelionVisDependencies) { - const timezone = timezoneProvider(uiSettings)(); + const timezone = getTimezone(uiSettings); return async function({ timeRange, diff --git a/src/legacy/core_plugins/vis_type_timelion/public/index.ts b/src/legacy/core_plugins/vis_type_timelion/public/index.ts index 98cc35877094e..6292e2ad3eb08 100644 --- a/src/legacy/core_plugins/vis_type_timelion/public/index.ts +++ b/src/legacy/core_plugins/vis_type_timelion/public/index.ts @@ -23,3 +23,5 @@ import { TimelionVisPlugin as Plugin } from './plugin'; export function plugin(initializerContext: PluginInitializerContext) { return new Plugin(initializerContext); } + +export { getTimezone } from './helpers/get_timezone'; diff --git a/src/legacy/core_plugins/vis_type_timelion/public/legacy_imports.ts b/src/legacy/core_plugins/vis_type_timelion/public/legacy_imports.ts index a00240ee06828..e7612b288fb24 100644 --- a/src/legacy/core_plugins/vis_type_timelion/public/legacy_imports.ts +++ b/src/legacy/core_plugins/vis_type_timelion/public/legacy_imports.ts @@ -19,6 +19,3 @@ export { npSetup, npStart } from 'ui/new_platform'; export { PluginsStart } from 'ui/new_platform/new_platform'; - -// @ts-ignore -export { timezoneProvider } from 'ui/vis/lib/timezone'; diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/legacy_imports.ts b/src/legacy/core_plugins/vis_type_timeseries/public/lib/get_timezone.ts similarity index 66% rename from src/legacy/core_plugins/vis_type_timeseries/public/legacy_imports.ts rename to src/legacy/core_plugins/vis_type_timeseries/public/lib/get_timezone.ts index 7cf0a12e8567c..f1e8fc56901e1 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/legacy_imports.ts +++ b/src/legacy/core_plugins/vis_type_timeseries/public/lib/get_timezone.ts @@ -17,5 +17,15 @@ * under the License. */ -// @ts-ignore -export { timezoneProvider } from 'ui/vis/lib/timezone'; +import moment from 'moment-timezone'; +import { IUiSettingsClient } from 'kibana/public'; + +export function getTimezone(config: IUiSettingsClient) { + if (config.isDefault('dateFormat:tz')) { + const detectedTimezone = moment.tz.guess(); + if (detectedTimezone) return detectedTimezone; + else return moment().format('Z'); + } else { + return config.get('dateFormat:tz', 'Browser'); + } +} diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/request_handler.js b/src/legacy/core_plugins/vis_type_timeseries/public/request_handler.js index 032ef335314d9..2cac1567a6eb7 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/request_handler.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/request_handler.js @@ -18,7 +18,7 @@ */ import { validateInterval } from './lib/validate_interval'; -import { timezoneProvider } from './legacy_imports'; +import { getTimezone } from './lib/get_timezone'; import { getUISettings, getDataStart, getCoreStart } from './services'; export const metricsRequestHandler = async ({ @@ -30,7 +30,7 @@ export const metricsRequestHandler = async ({ savedObjectId, }) => { const config = getUISettings(); - const timezone = timezoneProvider(config)(); + const timezone = getTimezone(config); const uiStateObj = uiState.get(visParams.type, {}); const parsedTimeRange = getDataStart().query.timefilter.timefilter.calculateBounds(timeRange); const scaledDataFormat = config.get('dateFormat:scaled'); diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js index bec76433eb8ac..3ce3aae2649e1 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/index.js @@ -31,8 +31,7 @@ import { TooltipType, } from '@elastic/charts'; import { EuiIcon } from '@elastic/eui'; - -import { timezoneProvider } from '../../../legacy_imports'; +import { getTimezone } from '../../../lib/get_timezone'; import { eventBus, ACTIVE_CURSOR } from '../../lib/active_cursor'; import { getUISettings } from '../../../services'; import { GRID_LINE_CONFIG, ICON_TYPES_MAP, STACKED_OPTIONS } from '../../constants'; @@ -87,7 +86,7 @@ export const TimeSeries = ({ const tooltipFormatter = decorateFormatter(xAxisFormatter); const uiSettings = getUISettings(); - const timeZone = timezoneProvider(uiSettings)(); + const timeZone = getTimezone(uiSettings); const hasBarChart = series.some(({ bars }) => bars.show); // compute the theme based on the bg color From ed5553120716c0918b3d66762fe9b4b7cc3d9d48 Mon Sep 17 00:00:00 2001 From: Peter Pisljar <peter.pisljar@gmail.com> Date: Mon, 23 Mar 2020 19:19:39 +0100 Subject: [PATCH 028/179] cleanup visualizations api (#59958) --- ...ns-data-public.aggconfigoptions.enabled.md | 11 + ...plugins-data-public.aggconfigoptions.id.md | 11 + ...in-plugins-data-public.aggconfigoptions.md | 22 ++ ...ins-data-public.aggconfigoptions.params.md | 11 + ...ins-data-public.aggconfigoptions.schema.md | 11 + ...ugins-data-public.aggconfigoptions.type.md | 11 + .../kibana-plugin-plugins-data-public.md | 1 + .../public/input_control_vis_type.ts | 2 - .../public/vis_controller.tsx | 2 +- .../discover/np_ready/angular/discover.js | 44 ++-- .../public/visualize/np_ready/breadcrumbs.ts | 2 +- .../visualize/np_ready/editor/editor.html | 9 +- .../visualize/np_ready/editor/editor.js | 144 ++++++----- .../np_ready/editor/visualization.js | 34 +-- .../np_ready/editor/visualization_editor.js | 19 +- .../public/visualize/np_ready/legacy_app.js | 63 +++-- .../public/visualize/np_ready/types.d.ts | 9 +- .../__tests__/region_map_visualization.js | 11 +- .../region_map/public/region_map_type.js | 8 +- .../public/region_map_visualization.js | 4 +- .../coordinate_maps_visualization.js | 11 +- .../public/base_maps_visualization.js | 17 +- .../tile_map/public/tile_map_type.js | 9 +- .../public/components/agg.test.tsx | 4 +- .../public/components/agg_common_props.ts | 5 +- .../public/components/agg_group.test.tsx | 10 +- .../public/components/agg_group.tsx | 7 +- .../public/components/agg_param_props.ts | 4 +- .../public/components/agg_params.test.tsx | 4 +- .../components/agg_params_helper.test.ts | 4 +- .../public/components/agg_params_helper.ts | 4 +- .../public/components/controls/field.test.tsx | 4 +- .../components/controls/percentiles.test.tsx | 4 +- .../public/components/controls/test_utils.ts | 4 +- .../public/components/sidebar/data_tab.tsx | 4 +- .../public/components/sidebar/sidebar.tsx | 47 ++-- .../components/sidebar/sidebar_title.tsx | 18 +- .../public/components/sidebar/state/index.ts | 32 +-- .../components/sidebar/state/reducers.ts | 67 +++-- .../public/default_editor.tsx | 67 ++--- .../public/default_editor_controller.tsx | 16 +- .../public/vis_options_props.tsx | 2 +- .../components/metric_vis_component.test.tsx | 6 +- .../components/metric_vis_component.tsx | 5 +- .../public/metric_vis_type.test.ts | 15 +- .../public/agg_table/__tests__/agg_table.js | 84 ++++--- .../agg_table/__tests__/agg_table_group.js | 35 ++- .../public/table_vis_controller.test.ts | 32 +-- .../vis_type_table/public/vis_controller.ts | 17 +- .../__tests__/tag_cloud_visualization.js | 10 +- .../components/tag_cloud_visualization.js | 15 +- .../public/tag_cloud_type.ts | 2 - .../public/components/timelion_vis.tsx | 4 +- .../public/components/vis_editor.js | 17 +- .../components/vis_editor_visualization.js | 13 +- .../public/editor_controller.js | 16 +- .../views/timeseries/utils/theme.ts | 1 + .../public/__tests__/vega_visualization.js | 7 +- .../vis_type_vega/public/vega_type.ts | 2 - .../public/vega_visualization.js | 11 +- .../__snapshots__/index.test.tsx.snap | 6 +- .../options/metrics_axes/index.test.tsx | 6 +- .../components/options/metrics_axes/index.tsx | 2 +- .../options/point_series/point_series.tsx | 4 +- .../vis_type_vislib/public/vis_controller.tsx | 5 +- .../__tests__/visualizations/pie_chart.js | 34 ++- .../vislib/components/legend/legend.tsx | 16 +- .../public/components/visualization.test.js | 11 +- .../public/components/visualization.tsx | 20 +- .../public/components/visualization_chart.tsx | 24 +- .../public/embeddable/get_index_pattern.ts | 8 +- .../public/embeddable/visualize_embeddable.ts | 130 ++++------ .../visualize_embeddable_factory.tsx | 19 +- .../public/expressions/{vis.js => vis.ts} | 81 +++--- .../expressions/visualization_renderer.tsx | 9 +- .../public/np_ready/public/index.ts | 3 +- .../public/legacy/build_pipeline.test.ts | 234 ++++++++---------- .../np_ready/public/legacy/build_pipeline.ts | 109 ++++---- .../public/legacy/update_status.test.js | 102 -------- .../np_ready/public/legacy/update_status.ts | 117 --------- .../public/np_ready/public/mocks.ts | 2 + .../public/np_ready/public/plugin.ts | 21 +- .../public/saved_visualizations/_saved_vis.ts | 135 +++++----- .../saved_visualization_references.test.ts | 6 +- .../public/np_ready/public/services.ts | 9 + .../public/np_ready/public/types.ts | 34 ++- .../public/np_ready/public/vis.ts | 201 ++++++++++++--- .../public/np_ready/public/vis_impl.d.ts | 55 ---- .../public/np_ready/public/vis_impl.js | 223 ----------------- src/plugins/data/public/index.ts | 1 + src/plugins/data/public/public.api.md | 46 ++-- .../public/persisted_state/persisted_state.ts | 2 +- 92 files changed, 1245 insertions(+), 1495 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.enabled.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.id.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.params.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.schema.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.type.md rename src/legacy/core_plugins/visualizations/public/np_ready/public/expressions/{vis.js => vis.ts} (67%) delete mode 100644 src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/update_status.test.js delete mode 100644 src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/update_status.ts delete mode 100644 src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.d.ts delete mode 100644 src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.js diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.enabled.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.enabled.md new file mode 100644 index 0000000000000..2ef8c797f4054 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.enabled.md @@ -0,0 +1,11 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigOptions](./kibana-plugin-plugins-data-public.aggconfigoptions.md) > [enabled](./kibana-plugin-plugins-data-public.aggconfigoptions.enabled.md) + +## AggConfigOptions.enabled property + +<b>Signature:</b> + +```typescript +enabled?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.id.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.id.md new file mode 100644 index 0000000000000..8939854ab19ca --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.id.md @@ -0,0 +1,11 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigOptions](./kibana-plugin-plugins-data-public.aggconfigoptions.md) > [id](./kibana-plugin-plugins-data-public.aggconfigoptions.id.md) + +## AggConfigOptions.id property + +<b>Signature:</b> + +```typescript +id?: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.md new file mode 100644 index 0000000000000..b841d9b04d6a7 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.md @@ -0,0 +1,22 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigOptions](./kibana-plugin-plugins-data-public.aggconfigoptions.md) + +## AggConfigOptions interface + +<b>Signature:</b> + +```typescript +export interface AggConfigOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [enabled](./kibana-plugin-plugins-data-public.aggconfigoptions.enabled.md) | <code>boolean</code> | | +| [id](./kibana-plugin-plugins-data-public.aggconfigoptions.id.md) | <code>string</code> | | +| [params](./kibana-plugin-plugins-data-public.aggconfigoptions.params.md) | <code>Record<string, any></code> | | +| [schema](./kibana-plugin-plugins-data-public.aggconfigoptions.schema.md) | <code>string</code> | | +| [type](./kibana-plugin-plugins-data-public.aggconfigoptions.type.md) | <code>IAggType</code> | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.params.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.params.md new file mode 100644 index 0000000000000..45219a837cc33 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.params.md @@ -0,0 +1,11 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigOptions](./kibana-plugin-plugins-data-public.aggconfigoptions.md) > [params](./kibana-plugin-plugins-data-public.aggconfigoptions.params.md) + +## AggConfigOptions.params property + +<b>Signature:</b> + +```typescript +params?: Record<string, any>; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.schema.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.schema.md new file mode 100644 index 0000000000000..b2b42eb2e5b4d --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.schema.md @@ -0,0 +1,11 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigOptions](./kibana-plugin-plugins-data-public.aggconfigoptions.md) > [schema](./kibana-plugin-plugins-data-public.aggconfigoptions.schema.md) + +## AggConfigOptions.schema property + +<b>Signature:</b> + +```typescript +schema?: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.type.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.type.md new file mode 100644 index 0000000000000..866065ce52ba6 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigoptions.type.md @@ -0,0 +1,11 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigOptions](./kibana-plugin-plugins-data-public.aggconfigoptions.md) > [type](./kibana-plugin-plugins-data-public.aggconfigoptions.type.md) + +## AggConfigOptions.type property + +<b>Signature:</b> + +```typescript +type: IAggType; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index f8516ec476e88..ea77d6f39389b 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -48,6 +48,7 @@ | Interface | Description | | --- | --- | +| [AggConfigOptions](./kibana-plugin-plugins-data-public.aggconfigoptions.md) | | | [AggParamOption](./kibana-plugin-plugins-data-public.aggparamoption.md) | | | [DataPublicPluginSetup](./kibana-plugin-plugins-data-public.datapublicpluginsetup.md) | | | [DataPublicPluginStart](./kibana-plugin-plugins-data-public.datapublicpluginstart.md) | | diff --git a/src/legacy/core_plugins/input_control_vis/public/input_control_vis_type.ts b/src/legacy/core_plugins/input_control_vis/public/input_control_vis_type.ts index dae6c9abb625e..023e6ebb7125c 100644 --- a/src/legacy/core_plugins/input_control_vis/public/input_control_vis_type.ts +++ b/src/legacy/core_plugins/input_control_vis/public/input_control_vis_type.ts @@ -22,7 +22,6 @@ import { i18n } from '@kbn/i18n'; import { createInputControlVisController } from './vis_controller'; import { getControlsTab } from './components/editor/controls_tab'; import { OptionsTab } from './components/editor/options_tab'; -import { Status } from '../../visualizations/public'; import { InputControlVisDependencies } from './plugin'; import { defaultFeedbackMessage } from '../../../../plugins/kibana_utils/common'; @@ -40,7 +39,6 @@ export function createInputControlVisTypeDefinition(deps: InputControlVisDepende defaultMessage: 'Create interactive controls for easy dashboard manipulation.', }), stage: 'experimental', - requiresUpdateStatus: [Status.PARAMS, Status.TIME], feedbackMessage: defaultFeedbackMessage, visualization: InputControlVisController, visConfig: { diff --git a/src/legacy/core_plugins/input_control_vis/public/vis_controller.tsx b/src/legacy/core_plugins/input_control_vis/public/vis_controller.tsx index 624d000dd8d7a..c0ab235c1b9d1 100644 --- a/src/legacy/core_plugins/input_control_vis/public/vis_controller.tsx +++ b/src/legacy/core_plugins/input_control_vis/public/vis_controller.tsx @@ -54,7 +54,7 @@ export const createInputControlVisController = (deps: InputControlVisDependencie .subscribe(this.queryBarUpdateHandler); } - async render(visData: any, visParams: VisParams, status: any) { + async render(visData: any, visParams: VisParams) { this.visParams = visParams; this.controls = []; this.controls = await this.initControls(); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js index 278317ec2e87b..8e4e77b2d18a6 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js @@ -672,7 +672,7 @@ function discoverController( // no timefield, no vis, nothing to update if (!getTimeField() || !$scope.vis) return; - const buckets = $scope.vis.getAggConfig().byTypeName('buckets'); + const buckets = $scope.vis.data.aggs.byTypeName('buckets'); if (buckets && buckets.length === 1) { $scope.bucketInterval = buckets[0].buckets.getInterval(); @@ -876,11 +876,11 @@ function discoverController( inspectorRequest.stats(getResponseInspectorStats($scope.searchSource, resp)).ok({ json: resp }); if (getTimeField()) { - const tabifiedData = tabifyAggResponse($scope.vis.aggs, resp); + const tabifiedData = tabifyAggResponse($scope.vis.data.aggs, resp); $scope.searchSource.rawResponse = resp; $scope.histogramData = discoverResponseHandler( tabifiedData, - getDimensions($scope.vis.aggs.aggs, $scope.timeRange) + getDimensions($scope.vis.data.aggs.aggs, $scope.timeRange) ); } @@ -1023,41 +1023,27 @@ function discoverController( }, ]; - if ($scope.vis) { - const visState = $scope.vis.getEnabledState(); - visState.aggs = visStateAggs; - - $scope.vis.setState(visState); - return; - } - - const visSavedObject = { - indexPattern: $scope.indexPattern.id, - visState: { - type: 'histogram', - title: savedSearch.title, - params: { - addLegend: false, - addTimeMarker: true, - }, + $scope.vis = visualizations.createVis('histogram', { + title: savedSearch.title, + params: { + addLegend: false, + addTimeMarker: true, + }, + data: { aggs: visStateAggs, + indexPattern: $scope.searchSource.getField('index').id, + searchSource: $scope.searchSource, }, - }; - - $scope.vis = visualizations.createVis( - $scope.searchSource.getField('index'), - visSavedObject.visState - ); - visSavedObject.vis = $scope.vis; + }); $scope.searchSource.onRequestStart((searchSource, options) => { if (!$scope.vis) return; - return $scope.vis.getAggConfig().onSearchRequestStart(searchSource, options); + return $scope.vis.data.aggs.onSearchRequestStart(searchSource, options); }); $scope.searchSource.setField('aggs', function() { if (!$scope.vis) return; - return $scope.vis.getAggConfig().toDsl(); + return $scope.vis.data.aggs.toDsl(); }); } diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/breadcrumbs.ts b/src/legacy/core_plugins/kibana/public/visualize/np_ready/breadcrumbs.ts index c334172805b9f..b6a63d50b205b 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/breadcrumbs.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/breadcrumbs.ts @@ -69,7 +69,7 @@ export function getEditBreadcrumbs($route: any) { return [ ...getLandingBreadcrumbs(), { - text: $route.current.locals.savedVis.title, + text: $route.current.locals.resolved.savedVis.title, }, ]; } diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html index 28baf21925cbe..0dcacd30fba4e 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html @@ -69,7 +69,8 @@ <visualization-embedded ng-if="!isVisible" class="visualize" - saved-obj="savedVis" + vis="vis" + embeddable-handler="embeddableHandler" ui-state="uiState" time-range="timeRange" filters="filters" @@ -89,13 +90,15 @@ </h1> <visualization-editor ng-if="isVisible" - saved-obj="savedVis" + vis="vis" + saved-search="savedSearch" + embeddable-handler="embeddableHandler" + event-emitter="eventEmitter" ui-state="uiState" time-range="timeRange" filters="filters" query="query" class="visEditor__content" - app-state="appState" /> </visualize-app> diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js index 7d1c29fbf48da..c5325ca3108b4 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js @@ -22,6 +22,7 @@ import _ from 'lodash'; import { Subscription } from 'rxjs'; import { map } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; +import { EventEmitter } from 'events'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -84,6 +85,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState uiSettings, I18nContext, setActiveUrl, + visualizations, } = getServices(); const { @@ -98,27 +100,63 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState ); // Retrieve the resolved SavedVis instance. - const savedVis = $route.current.locals.savedVis; + const { vis, savedVis, savedSearch, embeddableHandler } = $route.current.locals.resolved; + $scope.eventEmitter = new EventEmitter(); const _applyVis = () => { $scope.$apply(); }; // This will trigger a digest cycle. This is needed when vis is updated from a global angular like in visualize_embeddable.js. - savedVis.vis.on('apply', _applyVis); + $scope.eventEmitter.on('apply', _applyVis); // vis is instance of src/legacy/ui/public/vis/vis.js. // SearchSource is a promise-based stream of search results that can inherit from other search sources. - const { vis, searchSource, savedSearch } = savedVis; + const searchSource = vis.data.searchSource; $scope.vis = vis; + $scope.savedSearch = savedSearch; const $appStatus = { dirty: !savedVis.id, }; - vis.on('dirtyStateChange', ({ isDirty }) => { - vis.dirty = isDirty; - $scope.$digest(); + const defaultQuery = { + query: '', + language: + localStorage.get('kibana.userQueryLanguage') || uiSettings.get('search:queryLanguage'), + }; + + const visStateToEditorState = () => { + const savedVisState = visualizations.convertFromSerializedVis(vis.serialize()); + return { + uiState: vis.uiState.toJSON(), + query: vis.data.searchSource.getOwnField('query') || defaultQuery, + filters: vis.data.searchSource.getOwnField('filter') || [], + vis: { ...savedVisState.visState, title: vis.title }, + linked: !!savedVis.savedSearchId, + }; + }; + + const stateDefaults = visStateToEditorState(); + + const { stateContainer, stopStateSync } = useVisualizeAppState({ + stateDefaults, + kbnUrlStateStorage, + }); + + $scope.eventEmitter.on('dirtyStateChange', ({ isDirty }) => { + if (!isDirty) { + stateContainer.transitions.updateVisState(visStateToEditorState().vis); + } + $timeout(() => { + $scope.dirty = isDirty; + }); + }); + + $scope.eventEmitter.on('updateVis', () => { + embeddableHandler.reload(); }); + $scope.embeddableHandler = embeddableHandler; + $scope.topNavMenu = [ ...(visualizeCapabilities.save ? [ @@ -135,10 +173,10 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState ), testId: 'visualizeSaveButton', disableButton() { - return Boolean(vis.dirty); + return Boolean($scope.dirty); }, tooltip() { - if (vis.dirty) { + if ($scope.dirty) { return i18n.translate( 'kbn.visualize.topNavMenu.saveVisualizationDisabledButtonTooltip', { @@ -207,7 +245,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState }), testId: 'shareTopNavButton', run: anchorElement => { - const hasUnappliedChanges = vis.dirty; + const hasUnappliedChanges = $scope.dirty; const hasUnsavedChanges = $appStatus.dirty; share.toggleShareContextMenu({ anchorElement, @@ -233,17 +271,17 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState }), testId: 'openInspectorButton', disableButton() { - return !vis.hasInspector || !vis.hasInspector(); + return !embeddableHandler.hasInspector || !embeddableHandler.hasInspector(); }, run() { - const inspectorSession = vis.openInspector(); + const inspectorSession = embeddableHandler.openInspector(); // Close the inspector if this scope is destroyed (e.g. because the user navigates away). const removeWatch = $scope.$on('$destroy', () => inspectorSession.close()); // Remove that watch in case the user closes the inspector session herself. inspectorSession.onClose.finally(removeWatch); }, tooltip() { - if (!vis.hasInspector || !vis.hasInspector()) { + if (!embeddableHandler.hasInspector || !embeddableHandler.hasInspector()) { return i18n.translate('kbn.visualize.topNavMenu.openInspectorDisabledButtonTooltip', { defaultMessage: `This visualization doesn't support any inspectors.`, }); @@ -257,7 +295,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState defaultMessage: 'Refresh', }), run: function() { - vis.forceReload(); + embeddableHandler.reload(); }, testId: 'visualizeRefreshButton', }, @@ -267,28 +305,6 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState chrome.docTitle.change(savedVis.title); } - const defaultQuery = { - query: '', - language: - localStorage.get('kibana.userQueryLanguage') || uiSettings.get('search:queryLanguage'), - }; - - // Extract visualization state with filtered aggs. You can see these filtered aggs in the URL. - // Consists of things like aggs, params, listeners, title, type, etc. - const savedVisState = vis.getState(); - const stateDefaults = { - uiState: savedVis.uiStateJSON ? JSON.parse(savedVis.uiStateJSON) : {}, - query: searchSource.getOwnField('query') || defaultQuery, - filters: searchSource.getOwnField('filter') || [], - vis: savedVisState, - linked: !!savedVis.savedSearchId, - }; - - const { stateContainer, stopStateSync } = useVisualizeAppState({ - stateDefaults, - kbnUrlStateStorage, - }); - // sync initial app filters from state to filterManager filterManager.setAppFilters(_.cloneDeep(stateContainer.getState().filters)); // setup syncing of app filters between appState and filterManager @@ -315,7 +331,8 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState // appState then they won't be equal. if (!_.isEqual(stateContainer.getState().vis, stateDefaults.vis)) { try { - vis.setState(stateContainer.getState().vis); + const { aggs, ...visState } = stateContainer.getState().vis; + vis.setState({ ...visState, data: { aggs } }); } catch (error) { // stop syncing url updtes with the state to prevent extra syncing stopAllSyncing(); @@ -369,8 +386,8 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState }; function init() { - if (vis.indexPattern) { - $scope.indexPattern = vis.indexPattern; + if (vis.data.indexPattern) { + $scope.indexPattern = vis.data.indexPattern; } else { indexPatterns.getDefault().then(defaultIndexPattern => { $scope.indexPattern = defaultIndexPattern; @@ -379,22 +396,14 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState const initialState = stateContainer.getState(); - $scope.appState = { - // mock implementation of the legacy appState.save() - // this could be even replaced by passing only "updateAppState" callback - save() { - stateContainer.transitions.updateVisState(vis.getState()); - }, - }; - const handleLinkedSearch = linked => { if (linked && !savedVis.savedSearchId && savedSearch) { savedVis.savedSearchId = savedSearch.id; - vis.savedSearchId = savedSearch.id; + vis.data.savedSearchId = savedSearch.id; searchSource.setParent(savedSearch.searchSource); } else if (!linked && savedVis.savedSearchId) { delete savedVis.savedSearchId; - delete vis.savedSearchId; + delete vis.data.savedSearchId; } }; @@ -403,6 +412,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState 'uiState', stateContainer ); + vis.uiState = persistedState; $scope.uiState = persistedState; $scope.savedVis = savedVis; $scope.query = initialState.query; @@ -427,7 +437,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState $scope.showQueryBarTimePicker = () => { // tsvb loads without an indexPattern initially (TODO investigate). // hide timefilter only if timeFieldName is explicitly undefined. - const hasTimeField = vis.indexPattern ? !!vis.indexPattern.timeFieldName : true; + const hasTimeField = vis.data.indexPattern ? !!vis.data.indexPattern.timeFieldName : true; return vis.type.options.showTimePicker && hasTimeField; }; @@ -442,10 +452,24 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState updateSavedQueryFromUrl(state.savedQuery); // if the browser history was changed manually we need to reflect changes in the editor - if (!_.isEqual(vis.getState(), state.vis)) { - vis.setState(state.vis); - vis.forceReload(); - vis.emit('updateEditor'); + if ( + !_.isEqual( + { + ...visualizations.convertFromSerializedVis(vis.serialize()).visState, + title: vis.title, + }, + state.vis + ) + ) { + const { aggs, ...visState } = state.vis; + vis.setState({ + ...visState, + data: { + aggs, + }, + }); + embeddableHandler.reload(); + $scope.eventEmitter.emit('updateEditor'); } $appStatus.dirty = true; @@ -498,8 +522,8 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState const { query, linked, filters } = stateContainer.getState(); $scope.query = query; handleLinkedSearch(linked); - savedVis.searchSource.setField('query', query); - savedVis.searchSource.setField('filter', filters); + vis.data.searchSource.setField('query', query); + vis.data.searchSource.setField('filter', filters); $scope.$broadcast('render'); }; @@ -533,7 +557,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState } savedVis.destroy(); subscriptions.unsubscribe(); - $scope.vis.off('apply', _applyVis); + $scope.eventEmitter.off('apply', _applyVis); unsubscribePersisted(); unsubscribeStateUpdates(); @@ -556,7 +580,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState // If nothing has changed, trigger the fetch manually, otherwise it will happen as a result of the changes if (!isUpdate) { - $scope.vis.forceReload(); + embeddableHandler.reload(); } }; @@ -605,8 +629,10 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState title: savedVis.title, type: savedVis.type || stateContainer.getState().vis.type, }); + savedVis.searchSource.setField('query', stateContainer.getState().query); + savedVis.searchSource.setField('filter', stateContainer.getState().filters); savedVis.visState = stateContainer.getState().vis; - savedVis.uiStateJSON = angular.toJson($scope.uiState.getChanges()); + savedVis.uiStateJSON = angular.toJson($scope.uiState.toJSON()); $appStatus.dirty = false; return savedVis.save(saveOptions).then( @@ -720,7 +746,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState ); }; - vis.on('unlinkFromSavedSearch', unlinkFromSavedSearch); + $scope.eventEmitter.on('unlinkFromSavedSearch', unlinkFromSavedSearch); addHelpMenuToAppChrome(chrome, docLinks); diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization.js index c8acea168444f..fbabd6fc87c98 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization.js @@ -17,12 +17,12 @@ * under the License. */ -export function initVisualizationDirective(app, deps) { +export function initVisualizationDirective(app) { app.directive('visualizationEmbedded', function($timeout) { return { restrict: 'E', scope: { - savedObj: '=', + embeddableHandler: '=', uiState: '=?', timeRange: '=', filters: '=', @@ -31,24 +31,16 @@ export function initVisualizationDirective(app, deps) { }, link: function($scope, element) { $scope.renderFunction = async () => { - if (!$scope._handler) { - $scope._handler = await deps.embeddable - .getEmbeddableFactory('visualization') - .createFromObject($scope.savedObj, { - timeRange: $scope.timeRange, - filters: $scope.filters || [], - query: $scope.query, - appState: $scope.appState, - uiState: $scope.uiState, - }); - $scope._handler.render(element[0]); - } else { - $scope._handler.updateInput({ - timeRange: $scope.timeRange, - filters: $scope.filters || [], - query: $scope.query, - }); + if (!$scope.rendered) { + $scope.embeddableHandler.render(element[0]); + $scope.rendered = true; } + + $scope.embeddableHandler.updateInput({ + timeRange: $scope.timeRange, + filters: $scope.filters || [], + query: $scope.query, + }); }; $scope.$on('render', event => { @@ -59,8 +51,8 @@ export function initVisualizationDirective(app, deps) { }); $scope.$on('$destroy', () => { - if ($scope._handler) { - $scope._handler.destroy(); + if ($scope.embeddableHandler) { + $scope.embeddableHandler.destroy(); } }); }, diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js index f2d9cbe2ad84c..ef174dbaa5865 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js @@ -22,16 +22,23 @@ export function initVisEditorDirective(app, deps) { return { restrict: 'E', scope: { - savedObj: '=', + vis: '=', uiState: '=?', timeRange: '=', filters: '=', query: '=', - appState: '=', + savedSearch: '=', + embeddableHandler: '=', + eventEmitter: '=', }, link: function($scope, element) { - const Editor = $scope.savedObj.vis.type.editor || deps.DefaultVisualizationEditor; - const editor = new Editor(element[0], $scope.savedObj); + const Editor = $scope.vis.type.editor || deps.DefaultVisualizationEditor; + const editor = new Editor( + element[0], + $scope.vis, + $scope.eventEmitter, + $scope.embeddableHandler + ); $scope.renderFunction = () => { editor.render({ @@ -42,8 +49,8 @@ export function initVisEditorDirective(app, deps) { timeRange: $scope.timeRange, filters: $scope.filters, query: $scope.query, - appState: $scope.appState, - linked: !!$scope.savedObj.savedSearchId, + linked: !!$scope.vis.data.savedSearchId, + savedSearch: $scope.savedSearch, }); }; diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/legacy_app.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/legacy_app.js index 0f1d50b149cd9..b0b1ae31a02a5 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/legacy_app.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/legacy_app.js @@ -40,6 +40,50 @@ import { getCreateBreadcrumbs, getEditBreadcrumbs, } from './breadcrumbs'; +import { createSavedSearchesLoader } from '../../../../../../plugins/discover/public'; + +const getResolvedResults = deps => { + const { core, data, visualizations } = deps; + + const results = {}; + + return savedVis => { + results.savedVis = savedVis; + return visualizations + .convertToSerializedVis(savedVis) + .then(serializedVis => visualizations.createVis(serializedVis.type, serializedVis)) + .then(vis => { + if (vis.type.setup) { + return vis.type.setup(vis).catch(() => vis); + } + return vis; + }) + .then(vis => { + results.vis = vis; + return deps.embeddable.getEmbeddableFactory('visualization').createFromObject(results.vis, { + timeRange: data.query.timefilter.timefilter.getTime(), + filters: data.query.filterManager.getFilters(), + }); + }) + .then(embeddableHandler => { + results.embeddableHandler = embeddableHandler; + if (results.vis.data.savedSearchId) { + return createSavedSearchesLoader({ + savedObjectsClient: core.savedObjects.client, + indexPatterns: data.indexPatterns, + chrome: core.chrome, + overlays: core.overlays, + }).get(results.vis.data.savedSearchId); + } + }) + .then(savedSearch => { + if (savedSearch) { + results.savedSearch = savedSearch; + } + return results; + }); + }; +}; export function initVisualizeApp(app, deps) { initVisualizeAppDirective(app, deps); @@ -101,7 +145,7 @@ export function initVisualizeApp(app, deps) { template: editorTemplate, k7Breadcrumbs: getCreateBreadcrumbs, resolve: { - savedVis: function($route, history) { + resolved: function($route, history) { const { core, data, savedVisualizations, visualizations, toastNotifications } = deps; const visTypes = visualizations.all(); const visType = find(visTypes, { name: $route.current.params.type }); @@ -121,12 +165,7 @@ export function initVisualizeApp(app, deps) { return ensureDefaultIndexPattern(core, data, history) .then(() => savedVisualizations.get($route.current.params)) - .then(savedVis => { - if (savedVis.vis.type.setup) { - return savedVis.vis.type.setup(savedVis).catch(() => savedVis); - } - return savedVis; - }) + .then(getResolvedResults(deps)) .catch( redirectWhenMissing({ history, @@ -142,20 +181,16 @@ export function initVisualizeApp(app, deps) { template: editorTemplate, k7Breadcrumbs: getEditBreadcrumbs, resolve: { - savedVis: function($route, history) { + resolved: function($route, history) { const { chrome, core, data, savedVisualizations, toastNotifications } = deps; + return ensureDefaultIndexPattern(core, data, history) .then(() => savedVisualizations.get($route.current.params.id)) .then(savedVis => { chrome.recentlyAccessed.add(savedVis.getFullPath(), savedVis.title, savedVis.id); return savedVis; }) - .then(savedVis => { - if (savedVis.vis.type.setup) { - return savedVis.vis.type.setup(savedVis).catch(() => savedVis); - } - return savedVis; - }) + .then(getResolvedResults(deps)) .catch( redirectWhenMissing({ history, diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts b/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts index 01ce872aeb679..246a031f05769 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts @@ -29,8 +29,10 @@ import { PersistedState } from 'src/plugins/visualizations/public'; import { LegacyCoreStart } from 'kibana/public'; import { Vis } from 'src/legacy/core_plugins/visualizations/public'; import { VisSavedObject } from '../legacy_imports'; +import { SavedVisState } from '../../../../visualizations/public/np_ready/public/types'; +import { SavedSearch } from '../../../../../../plugins/discover/public'; -export type PureVisState = ReturnType<Vis['getCurrentState']>; +export type PureVisState = SavedVisState; export interface VisualizeAppState { filters: Filter[]; @@ -58,14 +60,13 @@ export interface VisualizeAppStateTransitions { } export interface EditorRenderProps { - appState: { save(): void }; core: LegacyCoreStart; data: DataPublicPluginStart; - embeddable: EmbeddableStart; filters: Filter[]; - uiState: PersistedState; timeRange: TimeRange; query?: Query; + savedSearch?: SavedSearch; + uiState: PersistedState; /** * Flag to determine if visualiztion is linked to the saved search */ diff --git a/src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js b/src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js index 6bdb5d00e67d8..23ca99791e92e 100644 --- a/src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js +++ b/src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js @@ -21,7 +21,6 @@ import expect from '@kbn/expect'; import ngMock from 'ng_mock'; import _ from 'lodash'; import ChoroplethLayer from '../choropleth_layer'; -import LogstashIndexPatternStubProvider from 'fixtures/stubbed_logstash_index_pattern'; import { ImageComparator } from 'test_utils/image_comparator'; import worldJson from './world.json'; import EMS_CATALOGUE from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_manifest.json'; @@ -38,13 +37,11 @@ import afterdatachangePng from './afterdatachange.png'; import afterdatachangeandresizePng from './afterdatachangeandresize.png'; import aftercolorchangePng from './aftercolorchange.png'; import changestartupPng from './changestartup.png'; -import { - setup as visualizationsSetup, - start as visualizationsStart, -} from '../../../visualizations/public/np_ready/public/legacy'; +import { setup as visualizationsSetup } from '../../../visualizations/public/np_ready/public/legacy'; import { createRegionMapVisualization } from '../region_map_visualization'; import { createRegionMapTypeDefinition } from '../region_map_type'; +import { ExprVis } from '../../../visualizations/public/np_ready/public/expressions/vis'; const THRESHOLD = 0.45; const PIXEL_DIFF = 96; @@ -52,7 +49,6 @@ const PIXEL_DIFF = 96; describe('RegionMapsVisualizationTests', function() { let domNode; let RegionMapsVisualization; - let indexPattern; let vis; let dependencies; @@ -115,7 +111,6 @@ describe('RegionMapsVisualizationTests', function() { } RegionMapsVisualization = createRegionMapVisualization(dependencies); - indexPattern = Private(LogstashIndexPatternStubProvider); ChoroplethLayer.prototype._makeJsonAjaxCall = async function() { //simulate network call @@ -158,7 +153,7 @@ describe('RegionMapsVisualizationTests', function() { imageComparator = new ImageComparator(); - vis = visualizationsStart.createVis(indexPattern, { + vis = new ExprVis({ type: 'region_map', }); diff --git a/src/legacy/core_plugins/region_map/public/region_map_type.js b/src/legacy/core_plugins/region_map/public/region_map_type.js index 9a1a76362e094..4faa3f92abb5a 100644 --- a/src/legacy/core_plugins/region_map/public/region_map_type.js +++ b/src/legacy/core_plugins/region_map/public/region_map_type.js @@ -20,7 +20,6 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { mapToLayerWithId } from './util'; import { createRegionMapVisualization } from './region_map_visualization'; -import { Status } from '../../visualizations/public'; import { RegionMapOptions } from './components/region_map_options'; import { truncatedColorSchemas } from '../../../../plugins/charts/public'; import { Schemas } from '../../vis_default_editor/public'; @@ -55,7 +54,6 @@ provided base maps, or add your own. Darker colors represent higher values.', showAllShapes: true, //still under consideration }, }, - requiresUpdateStatus: [Status.AGGS, Status.PARAMS, Status.RESIZE, Status.DATA, Status.UI_STATE], visualization, editorConfig: { optionsTemplate: props => <RegionMapOptions {...props} serviceSettings={serviceSettings} />, @@ -100,9 +98,7 @@ provided base maps, or add your own. Darker colors represent higher values.', }, ]), }, - setup: async savedVis => { - const vis = savedVis.vis; - + setup: async vis => { const tmsLayers = await serviceSettings.getTMSServices(); vis.type.editorConfig.collections.tmsLayers = tmsLayers; if (!vis.params.wms.selectedTmsLayer && tmsLayers.length) { @@ -146,7 +142,7 @@ provided base maps, or add your own. Darker colors represent higher values.', vis.params.selectedJoinField = selectedJoinField; } - return savedVis; + return vis; }, }; } diff --git a/src/legacy/core_plugins/region_map/public/region_map_visualization.js b/src/legacy/core_plugins/region_map/public/region_map_visualization.js index 8b5812052a395..25641ea76809d 100644 --- a/src/legacy/core_plugins/region_map/public/region_map_visualization.js +++ b/src/legacy/core_plugins/region_map/public/region_map_visualization.js @@ -39,8 +39,8 @@ export function createRegionMapVisualization({ serviceSettings, $injector, uiSet this._choroplethLayer = null; } - async render(esResponse, visParams, status) { - await super.render(esResponse, visParams, status); + async render(esResponse, visParams) { + await super.render(esResponse, visParams); if (this._choroplethLayer) { await this._choroplethLayer.whenDataLoaded(); } diff --git a/src/legacy/core_plugins/tile_map/public/__tests__/coordinate_maps_visualization.js b/src/legacy/core_plugins/tile_map/public/__tests__/coordinate_maps_visualization.js index 6a08405b5b6a5..3b8a7dfbed313 100644 --- a/src/legacy/core_plugins/tile_map/public/__tests__/coordinate_maps_visualization.js +++ b/src/legacy/core_plugins/tile_map/public/__tests__/coordinate_maps_visualization.js @@ -19,7 +19,6 @@ import expect from '@kbn/expect'; import ngMock from 'ng_mock'; -import LogstashIndexPatternStubProvider from 'fixtures/stubbed_logstash_index_pattern'; import { ImageComparator } from 'test_utils/image_comparator'; import dummyESResponse from './dummy_es_response.json'; import initial from './initial.png'; @@ -32,13 +31,11 @@ import EMS_TILES from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_ import EMS_STYLE_ROAD_MAP_BRIGHT from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_style_bright'; import EMS_STYLE_ROAD_MAP_DESATURATED from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_style_desaturated'; import EMS_STYLE_DARK_MAP from '../../../../ui/public/vis/__tests__/map/ems_mocks/sample_style_dark'; -import { - setup as visualizationsSetup, - start as visualizationsStart, -} from '../../../visualizations/public/np_ready/public/legacy'; +import { setup as visualizationsSetup } from '../../../visualizations/public/np_ready/public/legacy'; import { createTileMapVisualization } from '../tile_map_visualization'; import { createTileMapTypeDefinition } from '../tile_map_type'; +import { ExprVis } from '../../../visualizations/public/np_ready/public/expressions/vis'; function mockRawData() { const stack = [dummyESResponse]; @@ -67,7 +64,6 @@ let visRegComplete = false; describe('CoordinateMapsVisualizationTest', function() { let domNode; let CoordinateMapsVisualization; - let indexPattern; let vis; let dependencies; @@ -92,7 +88,6 @@ describe('CoordinateMapsVisualizationTest', function() { } CoordinateMapsVisualization = createTileMapVisualization(dependencies); - indexPattern = Private(LogstashIndexPatternStubProvider); getManifestStub = serviceSettings.__debugStubManifestCalls(async url => { //simulate network calls @@ -124,7 +119,7 @@ describe('CoordinateMapsVisualizationTest', function() { setupDOM('512px', '512px'); imageComparator = new ImageComparator(); - vis = visualizationsStart.createVis(indexPattern, { + vis = new ExprVis({ type: 'tile_map', }); vis.params = { diff --git a/src/legacy/core_plugins/tile_map/public/base_maps_visualization.js b/src/legacy/core_plugins/tile_map/public/base_maps_visualization.js index ebb0c24243263..d38159c91ef9f 100644 --- a/src/legacy/core_plugins/tile_map/public/base_maps_visualization.js +++ b/src/legacy/core_plugins/tile_map/public/base_maps_visualization.js @@ -63,28 +63,21 @@ export function BaseMapsVisualizationProvider(serviceSettings) { * @param status * @return {Promise} */ - async render(esResponse, visParams, status) { + async render(esResponse, visParams) { if (!this._kibanaMap) { //the visualization has been destroyed; return; } await this._mapIsLoaded; - - if (status.resize) { - this._kibanaMap.resize(); - } - if (status.params || status.aggs) { - this._params = visParams; - await this._updateParams(); - } + this._kibanaMap.resize(); + this._params = visParams; + await this._updateParams(); if (this._hasESResponseChanged(esResponse)) { await this._updateData(esResponse); } - if (status.uiState) { - this._kibanaMap.useUiStateFromVisualization(this.vis); - } + this._kibanaMap.useUiStateFromVisualization(this.vis); await this._whenBaseLayerIsLoaded(); } diff --git a/src/legacy/core_plugins/tile_map/public/tile_map_type.js b/src/legacy/core_plugins/tile_map/public/tile_map_type.js index 0809bf6ecbab6..39d39a4c8f8fc 100644 --- a/src/legacy/core_plugins/tile_map/public/tile_map_type.js +++ b/src/legacy/core_plugins/tile_map/public/tile_map_type.js @@ -23,7 +23,6 @@ import { i18n } from '@kbn/i18n'; import { convertToGeoJson } from 'ui/vis/map/convert_to_geojson'; import { Schemas } from '../../vis_default_editor/public'; -import { Status } from '../../visualizations/public'; import { createTileMapVisualization } from './tile_map_visualization'; import { TileMapOptions } from './components/tile_map_options'; import { MapTypes } from './map_types'; @@ -57,7 +56,6 @@ export function createTileMapTypeDefinition(dependencies) { wms: uiSettings.get('visualization:tileMap:WMSdefaults'), }, }, - requiresUpdateStatus: [Status.AGGS, Status.PARAMS, Status.RESIZE, Status.UI_STATE], requiresPartialRows: true, visualization: CoordinateMapsVisualization, responseHandler: convertToGeoJson, @@ -143,21 +141,20 @@ export function createTileMapTypeDefinition(dependencies) { }, ]), }, - setup: async savedVis => { - const vis = savedVis.vis; + setup: async vis => { let tmsLayers; try { tmsLayers = await serviceSettings.getTMSServices(); } catch (e) { - return savedVis; + return vis; } vis.type.editorConfig.collections.tmsLayers = tmsLayers; if (!vis.params.wms.selectedTmsLayer && tmsLayers.length) { vis.params.wms.selectedTmsLayer = tmsLayers[0]; } - return savedVis; + return vis; }, }; } diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg.test.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg.test.tsx index 7e715be25bff3..feb5b3caa023b 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg.test.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg.test.tsx @@ -22,12 +22,12 @@ import { mount, shallow } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { IndexPattern, IAggType, AggGroupNames } from 'src/plugins/data/public'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { DefaultEditorAgg, DefaultEditorAggProps } from './agg'; import { DefaultEditorAggParams } from './agg_params'; import { AGGS_ACTION_KEYS } from './agg_group_state'; import { Schema } from '../schemas'; +import { EditorVisState } from './sidebar/state/reducers'; jest.mock('./agg_params', () => ({ DefaultEditorAggParams: () => null, @@ -67,7 +67,7 @@ describe('DefaultEditorAgg component', () => { isLastBucket: false, isRemovable: false, metricAggs: [], - state: { params: {} } as VisState, + state: { params: {} } as EditorVisState, setAggParamValue, setStateParamValue, onAggTypeChange: () => {}, diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_common_props.ts b/src/legacy/core_plugins/vis_default_editor/public/components/agg_common_props.ts index ec92f511b6eee..3aae10879138a 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_common_props.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_common_props.ts @@ -17,9 +17,10 @@ * under the License. */ -import { VisState, VisParams } from 'src/legacy/core_plugins/visualizations/public'; +import { VisParams } from 'src/legacy/core_plugins/visualizations/public'; import { IAggType, IAggConfig, IAggGroupNames } from 'src/plugins/data/public'; import { Schema } from '../schemas'; +import { EditorVisState } from './sidebar/state/reducers'; type AggId = IAggConfig['id']; type AggParams = IAggConfig['params']; @@ -31,7 +32,7 @@ export interface DefaultEditorCommonProps { formIsTouched: boolean; groupName: IAggGroupNames; metricAggs: IAggConfig[]; - state: VisState; + state: EditorVisState; setAggParamValue: <T extends keyof AggParams>( aggId: AggId, paramName: T, diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.test.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.test.tsx index 63f5e696c99f4..5d02f0a2c759e 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.test.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.test.tsx @@ -20,12 +20,12 @@ import React from 'react'; import { mount, shallow } from 'enzyme'; import { act } from 'react-dom/test-utils'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { IAggConfigs, IAggConfig } from 'src/plugins/data/public'; import { DefaultEditorAggGroup, DefaultEditorAggGroupProps } from './agg_group'; import { DefaultEditorAgg } from './agg'; import { DefaultEditorAggAdd } from './agg_add'; import { Schema } from '../schemas'; +import { EditorVisState } from './sidebar/state/reducers'; jest.mock('@elastic/eui', () => ({ EuiTitle: 'eui-title', @@ -93,8 +93,8 @@ describe('DefaultEditorAgg component', () => { metricAggs: [], groupName: 'metrics', state: { - aggs, - } as VisState, + data: { aggs }, + } as EditorVisState, schemas: [ { name: 'metrics', @@ -147,8 +147,8 @@ describe('DefaultEditorAgg component', () => { }); expect(reorderAggs).toHaveBeenCalledWith( - defaultProps.state.aggs.aggs[0], - defaultProps.state.aggs.aggs[1] + defaultProps.state.data.aggs!.aggs[0], + defaultProps.state.data.aggs!.aggs[1] ); }); diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.tsx index 600612f2cf9d8..08b69ef37f528 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_group.tsx @@ -73,9 +73,10 @@ function DefaultEditorAggGroup({ const schemaNames = getSchemasByGroup(schemas, groupName).map(s => s.name); const group: IAggConfig[] = useMemo( () => - state.aggs.aggs.filter((agg: IAggConfig) => agg.schema && schemaNames.includes(agg.schema)) || - [], - [state.aggs.aggs, schemaNames] + state.data.aggs!.aggs.filter( + (agg: IAggConfig) => agg.schema && schemaNames.includes(agg.schema) + ) || [], + [state.data.aggs, schemaNames] ); const stats = { diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_param_props.ts b/src/legacy/core_plugins/vis_default_editor/public/components/agg_param_props.ts index 7c2852798b403..aec332e8674d7 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_param_props.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_param_props.ts @@ -18,10 +18,10 @@ */ import { IAggConfig, AggParam, IndexPatternField } from 'src/plugins/data/public'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { ComboBoxGroupedOptions } from '../utils'; import { EditorConfig } from './utils'; import { Schema } from '../schemas'; +import { EditorVisState } from './sidebar/state/reducers'; // NOTE: we cannot export the interface with export { InterfaceName } // as there is currently a bug on babel typescript transform plugin for it @@ -35,7 +35,7 @@ export interface AggParamCommonProps<T, P = AggParam> { formIsTouched: boolean; indexedFields?: ComboBoxGroupedOptions<IndexPatternField>; showValidation: boolean; - state: VisState; + state: EditorVisState; value?: T; metricAggs: IAggConfig[]; schemas: Schema[]; diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params.test.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params.test.tsx index cd6486b6a1532..1c49ebf40640e 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params.test.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params.test.tsx @@ -20,7 +20,6 @@ import React from 'react'; import { mount } from 'enzyme'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { IndexPattern, IAggConfig, AggGroupNames } from 'src/plugins/data/public'; import { DefaultEditorAggParams as PureDefaultEditorAggParams, @@ -28,6 +27,7 @@ import { } from './agg_params'; import { KibanaContextProvider } from '../../../../../plugins/kibana_react/public'; import { dataPluginMock } from '../../../../../plugins/data/public/mocks'; +import { EditorVisState } from './sidebar/state/reducers'; const mockEditorConfig = { useNormalizedEsInterval: { hidden: false, fixedValue: false }, @@ -108,7 +108,7 @@ describe('DefaultEditorAggParams component', () => { formIsTouched: false, indexPattern: {} as IndexPattern, metricAggs: [], - state: {} as VisState, + state: {} as EditorVisState, setAggParamValue, onAggTypeChange, setTouched, diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.test.ts b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.test.ts index f2ebbdc87a60a..bed2561341737 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.test.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.test.ts @@ -25,7 +25,6 @@ import { IndexPattern, IndexPatternField, } from 'src/plugins/data/public'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { getAggParamsToRender, getAggTypeOptions, @@ -34,6 +33,7 @@ import { import { FieldParamEditor, OrderByParamEditor } from './controls'; import { EditorConfig } from './utils'; import { Schema } from '../schemas'; +import { EditorVisState } from './sidebar/state/reducers'; jest.mock('../utils', () => ({ groupAndSortBy: jest.fn(() => ['indexedFields']), @@ -58,7 +58,7 @@ describe('DefaultEditorAggParams helpers', () => { hideCustomLabel: true, } as Schema, ]; - const state = {} as VisState; + const state = {} as EditorVisState; const metricAggs: IAggConfig[] = []; const emptyParams = { basic: [], diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.ts b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.ts index e07bf81697579..10590e1a59f4a 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_params_helper.ts @@ -28,7 +28,6 @@ import { IndexPattern, IndexPatternField, } from 'src/plugins/data/public'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { groupAndSortBy, ComboBoxGroupedOptions } from '../utils'; import { AggTypeState, AggParamsState } from './agg_params_state'; import { AggParamEditorProps } from './agg_param_props'; @@ -36,12 +35,13 @@ import { aggParamsMap } from './agg_params_map'; import { EditorConfig } from './utils'; import { Schema, getSchemaByName } from '../schemas'; import { search } from '../../../../../plugins/data/public'; +import { EditorVisState } from './sidebar/state/reducers'; interface ParamInstanceBase { agg: IAggConfig; editorConfig: EditorConfig; metricAggs: IAggConfig[]; - state: VisState; + state: EditorVisState; schemas: Schema[]; hideCustomLabel?: boolean; } diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.test.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.test.tsx index 1043431475494..b33149dc51a19 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.test.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.test.tsx @@ -23,9 +23,9 @@ import { mount, shallow, ReactWrapper } from 'enzyme'; import { EuiComboBoxProps, EuiComboBox } from '@elastic/eui'; import { IAggConfig, IndexPatternField } from 'src/plugins/data/public'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { ComboBoxGroupedOptions } from '../../utils'; import { FieldParamEditor, FieldParamEditorProps } from './field'; +import { EditorVisState } from '../sidebar/state/reducers'; function callComboBoxOnChange(comp: ReactWrapper, value: any = []) { const comboBoxProps = comp.find(EuiComboBox).props() as EuiComboBoxProps<any>; @@ -78,7 +78,7 @@ describe('FieldParamEditor component', () => { setValue, setValidity, setTouched, - state: {} as VisState, + state: {} as EditorVisState, metricAggs: [] as IAggConfig[], schemas: [], }; diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.test.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.test.tsx index 76eb12af8c4e2..82166440cf8e8 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.test.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/percentiles.test.tsx @@ -20,9 +20,9 @@ import React from 'react'; import { AggParamEditorProps } from '../agg_param_props'; import { IAggConfig } from 'src/plugins/data/public'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { mount } from 'enzyme'; import { PercentilesEditor } from './percentiles'; +import { EditorVisState } from '../sidebar/state/reducers'; describe('PercentilesEditor component', () => { let setValue: jest.Mock; @@ -45,7 +45,7 @@ describe('PercentilesEditor component', () => { setValue, setValidity, setTouched, - state: {} as VisState, + state: {} as EditorVisState, metricAggs: [] as IAggConfig[], schemas: [], }; diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/test_utils.ts b/src/legacy/core_plugins/vis_default_editor/public/components/controls/test_utils.ts index b816e61cce355..7c7431015d175 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/test_utils.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/test_utils.ts @@ -17,9 +17,9 @@ * under the License. */ -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { IAggConfig, AggParam } from 'src/plugins/data/public'; import { EditorConfig } from '../utils'; +import { EditorVisState } from '../sidebar/state/reducers'; export const aggParamCommonPropsMock = { agg: {} as IAggConfig, @@ -27,7 +27,7 @@ export const aggParamCommonPropsMock = { editorConfig: {} as EditorConfig, formIsTouched: false, metricAggs: [] as IAggConfig[], - state: {} as VisState, + state: {} as EditorVisState, showValidation: false, schemas: [], }; diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/data_tab.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/data_tab.tsx index 6f92c27e90ec1..a6a1980210be4 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/data_tab.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/data_tab.tsx @@ -21,7 +21,6 @@ import React, { useMemo, useCallback } from 'react'; import { findLast } from 'lodash'; import { EuiSpacer } from '@elastic/eui'; -import { VisState } from 'src/legacy/core_plugins/visualizations/public'; import { AggGroupNames, IAggConfig, @@ -40,6 +39,7 @@ import { } from './state'; import { AddSchema, ReorderAggs, DefaultEditorAggCommonProps } from '../agg_common_props'; import { ISchemas } from '../../schemas'; +import { EditorVisState } from './state/reducers'; export interface DefaultEditorDataTabProps { dispatch: React.Dispatch<EditorAction>; @@ -47,7 +47,7 @@ export interface DefaultEditorDataTabProps { isTabSelected: boolean; metricAggs: IAggConfig[]; schemas: ISchemas; - state: VisState; + state: EditorVisState; setTouched(isTouched: boolean): void; setValidity(modelName: string, value: boolean): void; setStateValue: DefaultEditorAggCommonProps['setStateParamValue']; diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx index 2508ef3a55537..04c931f593e5a 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx @@ -21,6 +21,7 @@ import React, { useMemo, useState, useCallback, KeyboardEventHandler, useEffect import { get, isEqual } from 'lodash'; import { i18n } from '@kbn/i18n'; import { keyCodes, EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EventEmitter } from 'events'; import { Vis } from 'src/legacy/core_plugins/visualizations/public'; import { DefaultEditorNavBar, OptionTab } from './navbar'; @@ -40,6 +41,7 @@ interface DefaultEditorSideBarProps { uiState: PersistedState; vis: Vis; isLinkedSearch: boolean; + eventEmitter: EventEmitter; savedSearch?: SavedSearch; } @@ -50,14 +52,17 @@ function DefaultEditorSideBar({ uiState, vis, isLinkedSearch, + eventEmitter, savedSearch, }: DefaultEditorSideBarProps) { const [selectedTab, setSelectedTab] = useState(optionTabs[0].name); const [isDirty, setDirty] = useState(false); - const [state, dispatch] = useEditorReducer(vis); + const [state, dispatch] = useEditorReducer(vis, eventEmitter); const { formState, setTouched, setValidity, resetValidity } = useEditorFormState(); - const responseAggs = useMemo(() => state.aggs.getResponseAggs(), [state.aggs]); + const responseAggs = useMemo(() => (state.data.aggs ? state.data.aggs.getResponseAggs() : []), [ + state.data.aggs, + ]); const metricSchemas = getSchemasByGroup(vis.type.schemas.all || [], AggGroupNames.Metrics).map( s => s.name ); @@ -90,17 +95,20 @@ function DefaultEditorSideBar({ const applyChanges = useCallback(() => { if (formState.invalid || !isDirty) { setTouched(true); - return; } - vis.setCurrentState(state); - vis.updateState(); - vis.emit('dirtyStateChange', { + vis.setState({ + ...vis.serialize(), + params: state.params, + data: { aggs: state.data.aggs ? (state.data.aggs.aggs.map(agg => agg.toJSON()) as any) : [] }, + }); + eventEmitter.emit('updateVis'); + eventEmitter.emit('dirtyStateChange', { isDirty: false, }); setTouched(false); - }, [vis, state, formState.invalid, setTouched, isDirty]); + }, [vis, state, formState.invalid, setTouched, isDirty, eventEmitter]); const onSubmit: KeyboardEventHandler<HTMLFormElement> = useCallback( event => { @@ -122,18 +130,22 @@ function DefaultEditorSideBar({ resetValidity(); } }; - vis.on('dirtyStateChange', changeHandler); + eventEmitter.on('dirtyStateChange', changeHandler); - return () => vis.off('dirtyStateChange', changeHandler); - }, [resetValidity, vis]); + return () => { + eventEmitter.off('dirtyStateChange', changeHandler); + }; + }, [resetValidity, eventEmitter]); // subscribe on external vis changes using browser history, for example press back button useEffect(() => { const resetHandler = () => dispatch(discardChanges(vis)); - vis.on('updateEditor', resetHandler); + eventEmitter.on('updateEditor', resetHandler); - return () => vis.off('updateEditor', resetHandler); - }, [dispatch, vis]); + return () => { + eventEmitter.off('updateEditor', resetHandler); + }; + }, [dispatch, vis, eventEmitter]); const dataTabProps = { dispatch, @@ -147,7 +159,7 @@ function DefaultEditorSideBar({ }; const optionTabProps = { - aggs: state.aggs, + aggs: state.data.aggs!, hasHistogramAgg, stateParams: state.params, vis, @@ -173,7 +185,12 @@ function DefaultEditorSideBar({ onKeyDownCapture={onSubmit} > {vis.type.requiresSearch && ( - <SidebarTitle isLinkedSearch={isLinkedSearch} savedSearch={savedSearch} vis={vis} /> + <SidebarTitle + isLinkedSearch={isLinkedSearch} + savedSearch={savedSearch} + vis={vis} + eventEmitter={eventEmitter} + /> )} {optionTabs.length > 1 && ( diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx index 876404851aed4..575ad5ae2a95c 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx @@ -18,6 +18,7 @@ */ import React, { useCallback, useState } from 'react'; +import { EventEmitter } from 'events'; import { EuiButton, EuiButtonEmpty, @@ -39,23 +40,24 @@ import { SavedSearch } from '../../../../../../plugins/discover/public'; interface LinkedSearchProps { savedSearch: SavedSearch; - vis: Vis; + eventEmitter: EventEmitter; } interface SidebarTitleProps { isLinkedSearch: boolean; savedSearch?: SavedSearch; vis: Vis; + eventEmitter: EventEmitter; } -export function LinkedSearch({ savedSearch, vis }: LinkedSearchProps) { +export function LinkedSearch({ savedSearch, eventEmitter }: LinkedSearchProps) { const [showPopover, setShowPopover] = useState(false); const closePopover = useCallback(() => setShowPopover(false), []); const onClickButtonLink = useCallback(() => setShowPopover(v => !v), []); const onClickUnlikFromSavedSearch = useCallback(() => { setShowPopover(false); - vis.emit('unlinkFromSavedSearch'); - }, [vis]); + eventEmitter.emit('unlinkFromSavedSearch'); + }, [eventEmitter]); const linkButtonAriaLabel = i18n.translate( 'visDefaultEditor.sidebar.savedSearch.linkButtonAriaLabel', @@ -151,20 +153,20 @@ export function LinkedSearch({ savedSearch, vis }: LinkedSearchProps) { ); } -function SidebarTitle({ savedSearch, vis, isLinkedSearch }: SidebarTitleProps) { +function SidebarTitle({ savedSearch, vis, isLinkedSearch, eventEmitter }: SidebarTitleProps) { return isLinkedSearch && savedSearch ? ( - <LinkedSearch savedSearch={savedSearch} vis={vis} /> + <LinkedSearch savedSearch={savedSearch} eventEmitter={eventEmitter} /> ) : vis.type.options.showIndexSelection ? ( <EuiTitle size="xs" className="visEditorSidebar__titleContainer eui-textTruncate"> <h2 title={i18n.translate('visDefaultEditor.sidebar.indexPatternAriaLabel', { defaultMessage: 'Index pattern: {title}', values: { - title: vis.indexPattern.title, + title: vis.data.indexPattern!.title, }, })} > - {vis.indexPattern.title} + {vis.data.indexPattern!.title} </h2> </EuiTitle> ) : ( diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/index.ts b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/index.ts index 6383ac866dcfc..11cbc3f93e9d3 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/index.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/index.ts @@ -17,20 +17,23 @@ * under the License. */ -import { useEffect, useReducer, useCallback } from 'react'; -import { isEqual } from 'lodash'; +import { useReducer, useCallback } from 'react'; +import { EventEmitter } from 'events'; -import { Vis, VisState, VisParams } from 'src/legacy/core_plugins/visualizations/public'; -import { createEditorStateReducer, initEditorState } from './reducers'; +import { Vis } from 'src/legacy/core_plugins/visualizations/public'; +import { createEditorStateReducer, initEditorState, EditorVisState } from './reducers'; import { EditorStateActionTypes } from './constants'; -import { EditorAction, updateStateParams } from './actions'; +import { EditorAction } from './actions'; import { useKibana } from '../../../../../../../plugins/kibana_react/public'; import { VisDefaultEditorKibanaServices } from '../../../types'; export * from './editor_form_state'; export * from './actions'; -export function useEditorReducer(vis: Vis): [VisState, React.Dispatch<EditorAction>] { +export function useEditorReducer( + vis: Vis, + eventEmitter: EventEmitter +): [EditorVisState, React.Dispatch<EditorAction>] { const { services } = useKibana<VisDefaultEditorKibanaServices>(); const [state, dispatch] = useReducer( createEditorStateReducer(services.data.search), @@ -38,28 +41,15 @@ export function useEditorReducer(vis: Vis): [VisState, React.Dispatch<EditorActi initEditorState ); - useEffect(() => { - const handleVisUpdate = (params: VisParams) => { - if (!isEqual(params, state.params)) { - dispatch(updateStateParams(params)); - } - }; - - // fires when visualization state changes, and we need to copy changes to editorState - vis.on('updateEditorStateParams', handleVisUpdate); - - return () => vis.off('updateEditorStateParams', handleVisUpdate); - }, [vis, state.params]); - const wrappedDispatch = useCallback( (action: EditorAction) => { dispatch(action); - vis.emit('dirtyStateChange', { + eventEmitter.emit('dirtyStateChange', { isDirty: action.type !== EditorStateActionTypes.DISCARD_CHANGES, }); }, - [vis] + [eventEmitter] ); return [state, wrappedDispatch]; diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/reducers.ts b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/reducers.ts index 67220fd9fd91b..6e5bec7c69c90 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/reducers.ts +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/state/reducers.ts @@ -19,35 +19,45 @@ import { cloneDeep } from 'lodash'; -import { Vis, VisState } from 'src/legacy/core_plugins/visualizations/public'; +import { Vis } from 'src/legacy/core_plugins/visualizations/public'; import { AggGroupNames, DataPublicPluginStart } from '../../../../../../../plugins/data/public'; import { EditorStateActionTypes } from './constants'; import { getEnabledMetricAggsCount } from '../../agg_group_helper'; import { EditorAction } from './actions'; function initEditorState(vis: Vis) { - return vis.copyCurrentState(true); + return { + ...vis.clone(), + }; } +export type EditorVisState = Pick<Vis, 'title' | 'description' | 'type' | 'params' | 'data'>; + const createEditorStateReducer = ({ aggs: { createAggConfigs }, -}: DataPublicPluginStart['search']) => (state: VisState, action: EditorAction): VisState => { +}: DataPublicPluginStart['search']) => ( + state: EditorVisState, + action: EditorAction +): EditorVisState => { switch (action.type) { case EditorStateActionTypes.ADD_NEW_AGG: { const { schema } = action.payload; const defaultConfig = - !state.aggs.aggs.find(agg => agg.schema === schema.name) && schema.defaults + !state.data.aggs!.aggs.find(agg => agg.schema === schema.name) && schema.defaults ? (schema as any).defaults.slice(0, schema.max) : { schema: schema.name }; - const aggConfig = state.aggs.createAggConfig(defaultConfig, { + const aggConfig = state.data.aggs!.createAggConfig(defaultConfig, { addToAggConfigs: false, }); aggConfig.brandNew = true; - const newAggs = [...state.aggs.aggs, aggConfig]; + const newAggs = [...state.data.aggs!.aggs, aggConfig]; return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs), + data: { + ...state.data, + aggs: createAggConfigs(state.data.indexPattern!, newAggs), + }, }; } @@ -58,7 +68,7 @@ const createEditorStateReducer = ({ case EditorStateActionTypes.CHANGE_AGG_TYPE: { const { aggId, value } = action.payload; - const newAggs = state.aggs.aggs.map(agg => { + const newAggs = state.data.aggs!.aggs.map(agg => { if (agg.id === aggId) { agg.type = value; @@ -70,14 +80,17 @@ const createEditorStateReducer = ({ return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs), + data: { + ...state.data, + aggs: createAggConfigs(state.data.indexPattern!, newAggs), + }, }; } case EditorStateActionTypes.SET_AGG_PARAM_VALUE: { const { aggId, paramName, value } = action.payload; - const newAggs = state.aggs.aggs.map(agg => { + const newAggs = state.data.aggs!.aggs.map(agg => { if (agg.id === aggId) { const parsedAgg = agg.toJSON(); @@ -95,7 +108,10 @@ const createEditorStateReducer = ({ return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs), + data: { + ...state.data, + aggs: createAggConfigs(state.data.indexPattern!, newAggs), + }, }; } @@ -113,7 +129,7 @@ const createEditorStateReducer = ({ case EditorStateActionTypes.REMOVE_AGG: { let isMetric = false; - const newAggs = state.aggs.aggs.filter(({ id, schema }) => { + const newAggs = state.data.aggs!.aggs.filter(({ id, schema }) => { if (id === action.payload.aggId) { const schemaDef = action.payload.schemas.find(s => s.name === schema); if (schemaDef && schemaDef.group === AggGroupNames.Metrics) { @@ -136,26 +152,36 @@ const createEditorStateReducer = ({ return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs), + data: { + ...state.data, + aggs: createAggConfigs(state.data.indexPattern!, newAggs), + }, }; } case EditorStateActionTypes.REORDER_AGGS: { const { sourceAgg, destinationAgg } = action.payload; - const destinationIndex = state.aggs.aggs.indexOf(destinationAgg); - const newAggs = [...state.aggs.aggs]; - newAggs.splice(destinationIndex, 0, newAggs.splice(state.aggs.aggs.indexOf(sourceAgg), 1)[0]); + const destinationIndex = state.data.aggs!.aggs.indexOf(destinationAgg); + const newAggs = [...state.data.aggs!.aggs]; + newAggs.splice( + destinationIndex, + 0, + newAggs.splice(state.data.aggs!.aggs.indexOf(sourceAgg), 1)[0] + ); return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs), + data: { + ...state.data, + aggs: createAggConfigs(state.data.indexPattern!, newAggs), + }, }; } case EditorStateActionTypes.TOGGLE_ENABLED_AGG: { const { aggId, enabled } = action.payload; - const newAggs = state.aggs.aggs.map(agg => { + const newAggs = state.data.aggs!.aggs.map(agg => { if (agg.id === aggId) { const parsedAgg = agg.toJSON(); @@ -170,7 +196,10 @@ const createEditorStateReducer = ({ return { ...state, - aggs: createAggConfigs(state.aggs.indexPattern, newAggs), + data: { + ...state.data, + aggs: createAggConfigs(state.data.indexPattern!, newAggs), + }, }; } diff --git a/src/legacy/core_plugins/vis_default_editor/public/default_editor.tsx b/src/legacy/core_plugins/vis_default_editor/public/default_editor.tsx index fa3213d244e7e..b504dfd6a55e9 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/default_editor.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/default_editor.tsx @@ -20,10 +20,6 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; import { EditorRenderProps } from '../../kibana/public/visualize/np_ready/types'; -import { - VisualizeEmbeddableContract as VisualizeEmbeddable, - VisualizeEmbeddableFactoryContract as VisualizeEmbeddableFactory, -} from '../../visualizations/public/'; import { PanelsContainer, Panel } from '../../../../plugins/kibana_react/public'; import './vis_type_agg_filter'; @@ -32,68 +28,44 @@ import { DefaultEditorControllerState } from './default_editor_controller'; import { getInitialWidth } from './editor_size'; function DefaultEditor({ - embeddable, - savedObj, + vis, uiState, timeRange, filters, - appState, optionTabs, query, + embeddableHandler, + eventEmitter, linked, + savedSearch, }: DefaultEditorControllerState & Omit<EditorRenderProps, 'data' | 'core'>) { const visRef = useRef<HTMLDivElement>(null); - const visHandler = useRef<VisualizeEmbeddable | null>(null); const [isCollapsed, setIsCollapsed] = useState(false); - const [factory, setFactory] = useState<VisualizeEmbeddableFactory | null>(null); - const { vis, savedSearch } = savedObj; const onClickCollapse = useCallback(() => { setIsCollapsed(value => !value); }, []); useEffect(() => { - async function visualize() { - if (!visRef.current || (!visHandler.current && factory)) { - return; - } - - if (!visHandler.current) { - const embeddableFactory = embeddable.getEmbeddableFactory( - 'visualization' - ) as VisualizeEmbeddableFactory; - setFactory(embeddableFactory); - - visHandler.current = (await embeddableFactory.createFromObject(savedObj, { - // should be look through createFromObject interface again because of "id" param - id: '', - uiState, - appState, - timeRange, - filters, - query, - })) as VisualizeEmbeddable; - - visHandler.current.render(visRef.current); - } else { - visHandler.current.updateInput({ - timeRange, - filters, - query, - }); - } + if (!visRef.current) { + return; } - visualize(); - }, [uiState, savedObj, timeRange, filters, appState, query, factory, embeddable]); + embeddableHandler.render(visRef.current); + setTimeout(() => { + eventEmitter.emit('apply'); + }); + + return () => embeddableHandler.destroy(); + }, [embeddableHandler, eventEmitter]); useEffect(() => { - return () => { - if (visHandler.current) { - visHandler.current.destroy(); - } - }; - }, []); + embeddableHandler.updateInput({ + timeRange, + filters, + query, + }); + }, [embeddableHandler, timeRange, filters, query]); const editorInitialWidth = getInitialWidth(vis.type.editorConfig.defaultSize); @@ -120,6 +92,7 @@ function DefaultEditor({ uiState={uiState} isLinkedSearch={linked} savedSearch={savedSearch} + eventEmitter={eventEmitter} /> </Panel> </PanelsContainer> diff --git a/src/legacy/core_plugins/vis_default_editor/public/default_editor_controller.tsx b/src/legacy/core_plugins/vis_default_editor/public/default_editor_controller.tsx index db910604eddd1..13fcabd799959 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/default_editor_controller.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/default_editor_controller.tsx @@ -21,18 +21,22 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; +import { EventEmitter } from 'events'; import { EditorRenderProps } from 'src/legacy/core_plugins/kibana/public/visualize/np_ready/types'; -import { VisSavedObject } from 'src/legacy/core_plugins/visualizations/public/'; +import { Vis } from 'src/legacy/core_plugins/visualizations/public/'; import { Storage } from '../../../../plugins/kibana_utils/public'; import { KibanaContextProvider } from '../../../../plugins/kibana_react/public'; import { DefaultEditor } from './default_editor'; import { DefaultEditorDataTab, OptionTab } from './components/sidebar'; +import { VisualizeEmbeddable } from '../../visualizations/public/np_ready/public/embeddable'; const localStorage = new Storage(window.localStorage); export interface DefaultEditorControllerState { - savedObj: VisSavedObject; + vis: Vis; + eventEmitter: EventEmitter; + embeddableHandler: VisualizeEmbeddable; optionTabs: OptionTab[]; } @@ -40,9 +44,9 @@ class DefaultEditorController { private el: HTMLElement; private state: DefaultEditorControllerState; - constructor(el: HTMLElement, savedObj: VisSavedObject) { + constructor(el: HTMLElement, vis: Vis, eventEmitter: EventEmitter, embeddableHandler: any) { this.el = el; - const { type: visType } = savedObj.vis; + const { type: visType } = vis; const optionTabs = [ ...(visType.schemas.buckets || visType.schemas.metrics @@ -71,8 +75,10 @@ class DefaultEditorController { ]; this.state = { - savedObj, + vis, optionTabs, + eventEmitter, + embeddableHandler, }; } diff --git a/src/legacy/core_plugins/vis_default_editor/public/vis_options_props.tsx b/src/legacy/core_plugins/vis_default_editor/public/vis_options_props.tsx index 2e8f20946c73a..3239e871a2465 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/vis_options_props.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/vis_options_props.tsx @@ -17,8 +17,8 @@ * under the License. */ +import { PersistedState } from 'src/plugins/visualizations/public'; import { IAggConfigs } from 'src/plugins/data/public'; -import { PersistedState } from '../../../../plugins/visualizations/public'; import { Vis } from '../../visualizations/public'; export interface VisOptionsProps<VisParamType = unknown> { diff --git a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.test.tsx b/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.test.tsx index 6a466c9cd0211..7ba4fe017522d 100644 --- a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.test.tsx +++ b/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.test.tsx @@ -20,12 +20,12 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { Vis } from 'src/legacy/core_plugins/visualizations/public'; import { MetricVisComponent, MetricVisComponentProps } from './metric_vis_component'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { npStart } from 'ui/new_platform'; import { fieldFormats } from '../../../../../plugins/data/public'; import { identity } from 'lodash'; +import { ExprVis } from '../../../visualizations/public/np_ready/public/expressions/vis'; jest.mock('ui/new_platform'); @@ -37,7 +37,7 @@ const baseVisData = { } as any; describe('MetricVisComponent', function() { - const vis: Vis = { + const vis: ExprVis = { params: { metric: { colorSchema: 'Green to Red', @@ -57,7 +57,7 @@ describe('MetricVisComponent', function() { const getComponent = (propOverrides: Partial<Props> = {} as Partial<Props>) => { const props: Props = { vis, - visParams: vis.params, + visParams: vis.params as any, visData: baseVisData, renderComplete: jest.fn(), ...propOverrides, diff --git a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.tsx b/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.tsx index a93bb618da31f..175458497a05e 100644 --- a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.tsx +++ b/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_component.tsx @@ -27,12 +27,13 @@ import { FieldFormatsContentType, IFieldFormat } from '../../../../../plugins/da import { KibanaDatatable } from '../../../../../plugins/expressions/public'; import { getHeatmapColors } from '../../../../../plugins/charts/public'; import { VisParams, MetricVisMetric } from '../types'; -import { SchemaConfig, Vis } from '../../../visualizations/public'; +import { SchemaConfig } from '../../../visualizations/public'; +import { ExprVis } from '../../../visualizations/public/np_ready/public/expressions/vis'; export interface MetricVisComponentProps { visParams: VisParams; visData: Input; - vis: Vis; + vis: ExprVis; renderComplete: () => void; } diff --git a/src/legacy/core_plugins/vis_type_metric/public/metric_vis_type.test.ts b/src/legacy/core_plugins/vis_type_metric/public/metric_vis_type.test.ts index cce5864aa50a1..c0bfa47bff502 100644 --- a/src/legacy/core_plugins/vis_type_metric/public/metric_vis_type.test.ts +++ b/src/legacy/core_plugins/vis_type_metric/public/metric_vis_type.test.ts @@ -61,11 +61,22 @@ describe('metric_vis - createMetricVisTypeDefinition', () => { labelTemplate: 'ip[{{value}}]', }); + const searchSource = { + getField: (name: string) => { + if (name === 'index') { + return stubIndexPattern; + } + }, + }; + // TODO: remove when Vis is converted to typescript. Only importing Vis as type // @ts-ignore - vis = visualizationsStart.createVis(stubIndexPattern, { + vis = visualizationsStart.createVis('metric', { type: 'metric', - aggs: [{ id: '1', type: 'top_hits', schema: 'metric', params: { field: 'ip' } }], + data: { + searchSource, + aggs: [{ id: '1', type: 'top_hits', schema: 'metric', params: { field: 'ip' } }], + }, }); vis.params.dimensions = { diff --git a/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table.js b/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table.js index 8edef2ea16353..211b79e915038 100644 --- a/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table.js +++ b/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table.js @@ -50,57 +50,73 @@ describe('Table Vis - AggTable Directive', function() { const tabifiedData = {}; const init = () => { - const vis1 = visualizationsStart.createVis(indexPattern, 'table'); - tabifiedData.metricOnly = tabifyAggResponse(vis1.aggs, metricOnly); + const searchSource = { + getField: name => { + if (name === 'index') { + return indexPattern; + } + }, + }; + const vis1 = visualizationsStart.createVis('table', { + type: 'table', + data: { searchSource, aggs: [] }, + }); + tabifiedData.metricOnly = tabifyAggResponse(vis1.data.aggs, metricOnly); - const vis2 = visualizationsStart.createVis(indexPattern, { + const vis2 = visualizationsStart.createVis('table', { type: 'table', params: { showMetricsAtAllLevels: true, }, - aggs: [ - { type: 'avg', schema: 'metric', params: { field: 'bytes' } }, - { type: 'terms', schema: 'bucket', params: { field: 'extension' } }, - { type: 'terms', schema: 'bucket', params: { field: 'geo.src' } }, - { type: 'terms', schema: 'bucket', params: { field: 'machine.os' } }, - ], + data: { + aggs: [ + { type: 'avg', schema: 'metric', params: { field: 'bytes' } }, + { type: 'terms', schema: 'bucket', params: { field: 'extension' } }, + { type: 'terms', schema: 'bucket', params: { field: 'geo.src' } }, + { type: 'terms', schema: 'bucket', params: { field: 'machine.os' } }, + ], + searchSource, + }, }); - vis2.aggs.aggs.forEach(function(agg, i) { + vis2.data.aggs.aggs.forEach(function(agg, i) { agg.id = 'agg_' + (i + 1); }); - tabifiedData.threeTermBuckets = tabifyAggResponse(vis2.aggs, threeTermBuckets, { + tabifiedData.threeTermBuckets = tabifyAggResponse(vis2.data.aggs, threeTermBuckets, { metricsAtAllLevels: true, }); - const vis3 = visualizationsStart.createVis(indexPattern, { + const vis3 = visualizationsStart.createVis('table', { type: 'table', - aggs: [ - { type: 'avg', schema: 'metric', params: { field: 'bytes' } }, - { type: 'min', schema: 'metric', params: { field: '@timestamp' } }, - { type: 'terms', schema: 'bucket', params: { field: 'extension' } }, - { - type: 'date_histogram', - schema: 'bucket', - params: { field: '@timestamp', interval: 'd' }, - }, - { - type: 'derivative', - schema: 'metric', - params: { metricAgg: 'custom', customMetric: { id: '5-orderAgg', type: 'count' } }, - }, - { - type: 'top_hits', - schema: 'metric', - params: { field: 'bytes', aggregate: { val: 'min' }, size: 1 }, - }, - ], + data: { + aggs: [ + { type: 'avg', schema: 'metric', params: { field: 'bytes' } }, + { type: 'min', schema: 'metric', params: { field: '@timestamp' } }, + { type: 'terms', schema: 'bucket', params: { field: 'extension' } }, + { + type: 'date_histogram', + schema: 'bucket', + params: { field: '@timestamp', interval: 'd' }, + }, + { + type: 'derivative', + schema: 'metric', + params: { metricAgg: 'custom', customMetric: { id: '5-orderAgg', type: 'count' } }, + }, + { + type: 'top_hits', + schema: 'metric', + params: { field: 'bytes', aggregate: { val: 'min' }, size: 1 }, + }, + ], + searchSource, + }, }); - vis3.aggs.aggs.forEach(function(agg, i) { + vis3.data.aggs.aggs.forEach(function(agg, i) { agg.id = 'agg_' + (i + 1); }); tabifiedData.oneTermOneHistogramBucketWithTwoMetricsOneTopHitOneDerivative = tabifyAggResponse( - vis3.aggs, + vis3.data.aggs, oneTermOneHistogramBucketWithTwoMetricsOneTopHitOneDerivative ); }; diff --git a/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table_group.js b/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table_group.js index 89900d2144030..77f817e44ba79 100644 --- a/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table_group.js +++ b/src/legacy/core_plugins/vis_type_table/public/agg_table/__tests__/agg_table_group.js @@ -38,22 +38,35 @@ describe('Table Vis - AggTableGroup Directive', function() { const tabifiedData = {}; const init = () => { - const vis1 = visualizationsStart.createVis(indexPattern, 'table'); - tabifiedData.metricOnly = tabifyAggResponse(vis1.aggs, metricOnly); + const searchSource = { + getField: name => { + if (name === 'index') { + return indexPattern; + } + }, + }; + const vis1 = visualizationsStart.createVis('table', { + type: 'table', + data: { searchSource, aggs: [] }, + }); + tabifiedData.metricOnly = tabifyAggResponse(vis1.data.aggs, metricOnly); - const vis2 = visualizationsStart.createVis(indexPattern, { + const vis2 = visualizationsStart.createVis('pie', { type: 'pie', - aggs: [ - { type: 'avg', schema: 'metric', params: { field: 'bytes' } }, - { type: 'terms', schema: 'split', params: { field: 'extension' } }, - { type: 'terms', schema: 'segment', params: { field: 'geo.src' } }, - { type: 'terms', schema: 'segment', params: { field: 'machine.os' } }, - ], + data: { + aggs: [ + { type: 'avg', schema: 'metric', params: { field: 'bytes' } }, + { type: 'terms', schema: 'split', params: { field: 'extension' } }, + { type: 'terms', schema: 'segment', params: { field: 'geo.src' } }, + { type: 'terms', schema: 'segment', params: { field: 'machine.os' } }, + ], + searchSource, + }, }); - vis2.aggs.aggs.forEach(function(agg, i) { + vis2.data.aggs.aggs.forEach(function(agg, i) { agg.id = 'agg_' + (i + 1); }); - tabifiedData.threeTermBuckets = tabifyAggResponse(vis2.aggs, threeTermBuckets); + tabifiedData.threeTermBuckets = tabifyAggResponse(vis2.data.aggs, threeTermBuckets); }; const initLocalAngular = () => { diff --git a/src/legacy/core_plugins/vis_type_table/public/table_vis_controller.test.ts b/src/legacy/core_plugins/vis_type_table/public/table_vis_controller.test.ts index 327a47093f535..ad56607e9296c 100644 --- a/src/legacy/core_plugins/vis_type_table/public/table_vis_controller.test.ts +++ b/src/legacy/core_plugins/vis_type_table/public/table_vis_controller.test.ts @@ -118,20 +118,22 @@ describe('Table Vis - Controller', () => { return ({ type: tableVisTypeDefinition, params: Object.assign({}, tableVisTypeDefinition.visConfig.defaults, params), - aggs: createAggConfigs(stubIndexPattern, [ - { type: 'count', schema: 'metric' }, - { - type: 'range', - schema: 'bucket', - params: { - field: 'bytes', - ranges: [ - { from: 0, to: 1000 }, - { from: 1000, to: 2000 }, - ], + data: { + aggs: createAggConfigs(stubIndexPattern, [ + { type: 'count', schema: 'metric' }, + { + type: 'range', + schema: 'bucket', + params: { + field: 'bytes', + ranges: [ + { from: 0, to: 1000 }, + { from: 1000, to: 2000 }, + ], + }, }, - }, - ]), + ]), + }, } as unknown) as Vis; } @@ -151,11 +153,11 @@ describe('Table Vis - Controller', () => { // basically a parameterized beforeEach function initController(vis: Vis) { - vis.aggs.aggs.forEach((agg: IAggConfig, i: number) => { + vis.data.aggs!.aggs.forEach((agg: IAggConfig, i: number) => { agg.id = 'agg_' + (i + 1); }); - tabifiedResponse = tabifyAggResponse(vis.aggs, oneRangeBucket); + tabifiedResponse = tabifyAggResponse(vis.data.aggs!, oneRangeBucket); $rootScope.vis = vis; $rootScope.visParams = vis.params; $rootScope.uiState = { diff --git a/src/legacy/core_plugins/vis_type_table/public/vis_controller.ts b/src/legacy/core_plugins/vis_type_table/public/vis_controller.ts index 2d27a99bdd8af..2feaad9f4e6b6 100644 --- a/src/legacy/core_plugins/vis_type_table/public/vis_controller.ts +++ b/src/legacy/core_plugins/vis_type_table/public/vis_controller.ts @@ -19,12 +19,12 @@ import angular, { IModule, auto, IRootScopeService, IScope, ICompileService } from 'angular'; import $ from 'jquery'; -import { isEqual } from 'lodash'; -import { Vis, VisParams } from '../../visualizations/public'; +import { VisParams } from '../../visualizations/public'; import { npStart } from './legacy_imports'; import { getAngularModule } from './get_inner_angular'; import { initTableVisLegacyModule } from './table_vis_legacy_module'; +import { ExprVis } from '../../visualizations/public/np_ready/public/expressions/vis'; const innerAngularName = 'kibana/table_vis'; @@ -32,12 +32,12 @@ export class TableVisualizationController { private tableVisModule: IModule | undefined; private injector: auto.IInjectorService | undefined; el: JQuery<Element>; - vis: Vis; + vis: ExprVis; $rootScope: IRootScopeService | null = null; $scope: (IScope & { [key: string]: any }) | undefined; $compile: ICompileService | undefined; - constructor(domeElement: Element, vis: Vis) { + constructor(domeElement: Element, vis: ExprVis) { this.el = $(domeElement); this.vis = vis; } @@ -60,7 +60,7 @@ export class TableVisualizationController { } } - async render(esResponse: object, visParams: VisParams, status: { [key: string]: boolean }) { + async render(esResponse: object, visParams: VisParams) { this.initLocalAngular(); return new Promise(async (resolve, reject) => { @@ -77,15 +77,10 @@ export class TableVisualizationController { this.$scope.visState = { params: visParams }; this.$scope.esResponse = esResponse; - if (!isEqual(this.$scope.visParams, visParams)) { - this.vis.emit('updateEditorStateParams', visParams); - } - this.$scope.visParams = visParams; this.$scope.renderComplete = resolve; this.$scope.renderFailed = reject; this.$scope.resize = Date.now(); - this.$scope.updateStatus = status; this.$scope.$apply(); }; @@ -93,7 +88,7 @@ export class TableVisualizationController { this.$scope = this.$rootScope.$new(); this.$scope.uiState = this.vis.getUiState(); updateScope(); - this.el.find('div').append(this.$compile(this.vis.type.visConfig.template)(this.$scope)); + this.el.find('div').append(this.$compile(this.vis.type!.visConfig.template)(this.$scope)); this.$scope.$apply(); } else { updateScope(); diff --git a/src/legacy/core_plugins/vis_type_tagcloud/public/components/__tests__/tag_cloud_visualization.js b/src/legacy/core_plugins/vis_type_tagcloud/public/components/__tests__/tag_cloud_visualization.js index 3091b3340cd6d..6f54744a2f508 100644 --- a/src/legacy/core_plugins/vis_type_tagcloud/public/components/__tests__/tag_cloud_visualization.js +++ b/src/legacy/core_plugins/vis_type_tagcloud/public/components/__tests__/tag_cloud_visualization.js @@ -19,7 +19,6 @@ import expect from '@kbn/expect'; import ngMock from 'ng_mock'; -import LogstashIndexPatternStubProvider from 'fixtures/stubbed_logstash_index_pattern'; import { start as visualizationsStart } from '../../../../../core_plugins/visualizations/public/np_ready/public/legacy'; import { ImageComparator } from 'test_utils/image_comparator'; import { createTagCloudVisualization } from '../tag_cloud_visualization'; @@ -36,7 +35,6 @@ const PIXEL_DIFF = 64; describe('TagCloudVisualizationTest', function() { let domNode; - let indexPattern; let vis; let imageComparator; @@ -66,22 +64,18 @@ describe('TagCloudVisualizationTest', function() { }); beforeEach(ngMock.module('kibana')); - beforeEach( - ngMock.inject(Private => { - indexPattern = Private(LogstashIndexPatternStubProvider); - }) - ); describe('TagCloudVisualization - basics', function() { beforeEach(async function() { setupDOM('512px', '512px'); imageComparator = new ImageComparator(); - vis = visualizationsStart.createVis(indexPattern, { + vis = visualizationsStart.createVis('tagcloud', { type: 'tagcloud', params: { bucket: { accessor: 0, format: {} }, metric: { accessor: 0, format: {} }, }, + data: {}, }); }); diff --git a/src/legacy/core_plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.js b/src/legacy/core_plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.js index 114643c9a74e0..04f447bf78d50 100644 --- a/src/legacy/core_plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.js +++ b/src/legacy/core_plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.js @@ -79,17 +79,10 @@ export function createTagCloudVisualization({ colors }) { render(<Label ref={this._label} />, this._labelNode); } - async render(data, visParams, status) { - if (!(status.resize || status.data || status.params)) return; - - if (status.params || status.data) { - this._updateParams(visParams); - this._updateData(data); - } - - if (status.resize) { - this._resize(); - } + async render(data, visParams) { + this._updateParams(visParams); + this._updateData(data); + this._resize(); await this._renderComplete$.pipe(take(1)).toPromise(); diff --git a/src/legacy/core_plugins/vis_type_tagcloud/public/tag_cloud_type.ts b/src/legacy/core_plugins/vis_type_tagcloud/public/tag_cloud_type.ts index 9a522fe6e648e..5a8cc3004a315 100644 --- a/src/legacy/core_plugins/vis_type_tagcloud/public/tag_cloud_type.ts +++ b/src/legacy/core_plugins/vis_type_tagcloud/public/tag_cloud_type.ts @@ -20,7 +20,6 @@ import { i18n } from '@kbn/i18n'; import { Schemas } from '../../vis_default_editor/public'; -import { Status } from '../../visualizations/public'; import { TagCloudOptions } from './components/tag_cloud_options'; @@ -44,7 +43,6 @@ export const createTagCloudVisTypeDefinition = (deps: TagCloudVisDependencies) = showLabel: true, }, }, - requiresUpdateStatus: [Status.PARAMS, Status.RESIZE, Status.DATA], visualization: createTagCloudVisualization(deps), editorConfig: { collections: { diff --git a/src/legacy/core_plugins/vis_type_timelion/public/components/timelion_vis.tsx b/src/legacy/core_plugins/vis_type_timelion/public/components/timelion_vis.tsx index 9e11fd5d3f45c..f55d1602ea342 100644 --- a/src/legacy/core_plugins/vis_type_timelion/public/components/timelion_vis.tsx +++ b/src/legacy/core_plugins/vis_type_timelion/public/components/timelion_vis.tsx @@ -20,16 +20,16 @@ import React from 'react'; import { IUiSettingsClient } from 'kibana/public'; -import { Vis } from 'src/legacy/core_plugins/visualizations/public'; import { ChartComponent } from './chart'; import { VisParams } from '../timelion_vis_fn'; import { TimelionSuccessResponse } from '../helpers/timelion_request_handler'; +import { ExprVis } from '../../../visualizations/public/np_ready/public/expressions/vis'; export interface TimelionVisComponentProp { config: IUiSettingsClient; renderComplete(): void; updateStatus: object; - vis: Vis; + vis: ExprVis; visData: TimelionSuccessResponse; visParams: VisParams; } diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor.js index ff2546f75c51a..b4845696fc8c0 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor.js @@ -65,7 +65,7 @@ export class VisEditor extends Component { } get uiState() { - return this.props.vis.getUiState(); + return this.props.vis.uiState; } getConfig = (...args) => { @@ -73,17 +73,15 @@ export class VisEditor extends Component { }; handleUiState = (field, value) => { - this.props.vis.uiStateVal(field, value); + this.props.vis.uiState.set(field, value); }; updateVisState = debounce(() => { this.props.vis.params = this.state.model; - this.props.vis.updateState(); - // This check should be redundant, since this method should only be called when we're in editor - // mode where there's also an appState passed into us. - if (this.props.appState) { - this.props.appState.save(); - } + this.props.eventEmitter.emit('updateVis'); + this.props.eventEmitter.emit('dirtyStateChange', { + isDirty: false, + }); }, VIS_STATE_DEBOUNCE_DELAY); isValidKueryQuery = filterQuery => { @@ -184,7 +182,8 @@ export class VisEditor extends Component { dirty={this.state.dirty} autoApply={this.state.autoApply} model={model} - savedObj={this.props.savedObj} + embeddableHandler={this.props.embeddableHandler} + vis={this.props.vis} timeRange={this.props.timeRange} uiState={this.uiState} onCommit={this.handleCommit} diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor_visualization.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor_visualization.js index c45a4d68e8aad..fbd17d99be9bf 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor_visualization.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor_visualization.js @@ -29,7 +29,6 @@ import { AUTO_INTERVAL, } from './lib/get_interval'; import { PANEL_TYPES } from '../../../../../plugins/vis_type_timeseries/common/panel_types'; -import { start as embeddables } from '../../../embeddable_api/public/np_ready/public/legacy'; const MIN_CHART_HEIGHT = 300; @@ -70,15 +69,9 @@ class VisEditorVisualizationUI extends Component { return; } - const { timeRange, savedObj, onDataChange } = this.props; + const { onDataChange, embeddableHandler } = this.props; - this._handler = await embeddables - .getEmbeddableFactory('visualization') - .createFromObject(savedObj, { - vis: {}, - timeRange: timeRange, - filters: [], - }); + this._handler = embeddableHandler; await this._handler.render(this._visEl.current); this._subscription = this._handler.handler.data$.subscribe(data => { @@ -285,7 +278,7 @@ VisEditorVisualizationUI.propTypes = { onCommit: PropTypes.func, uiState: PropTypes.object, onToggleAutoApply: PropTypes.func, - savedObj: PropTypes.object, + embeddableHandler: PropTypes.object, timeRange: PropTypes.object, dirty: PropTypes.bool, autoApply: PropTypes.bool, diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/editor_controller.js b/src/legacy/core_plugins/vis_type_timeseries/public/editor_controller.js index 4d029553145da..16a6348712065 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/editor_controller.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/editor_controller.js @@ -23,12 +23,15 @@ import { fetchIndexPatternFields } from './lib/fetch_fields'; import { getSavedObjectsClient, getUISettings, getI18n } from './services'; export class EditorController { - constructor(el, savedObj) { + constructor(el, vis, eventEmitter, embeddableHandler) { this.el = el; + this.embeddableHandler = embeddableHandler; + this.eventEmitter = eventEmitter; + this.state = { - savedObj: savedObj, - vis: savedObj.vis, + fields: [], + vis: vis, isLoaded: false, }; } @@ -47,7 +50,7 @@ export class EditorController { this.state.vis.params.default_index_pattern = title; this.state.vis.params.default_timefield = timeFieldName; - this.state.vis.fields = await fetchIndexPatternFields(this.state.vis); + this.state.fields = await fetchIndexPatternFields(this.state.vis); this.state.isLoaded = true; }; @@ -67,13 +70,14 @@ export class EditorController { <Component config={getUISettings()} vis={this.state.vis} - visFields={this.state.vis.fields} + visFields={this.state.fields} visParams={this.state.vis.params} - savedObj={this.state.savedObj} timeRange={params.timeRange} renderComplete={() => {}} isEditorMode={true} appState={params.appState} + embeddableHandler={this.embeddableHandler} + eventEmitter={this.eventEmitter} /> </I18nContext>, this.el diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/theme.ts b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/theme.ts index a25d5e1ce1d35..2694732aa381d 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/theme.ts +++ b/src/legacy/core_plugins/vis_type_timeseries/public/visualizations/views/timeseries/utils/theme.ts @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +// @ts-ignore import colorJS from 'color'; import { Theme, LIGHT_THEME, DARK_THEME } from '@elastic/charts'; diff --git a/src/legacy/core_plugins/vis_type_vega/public/__tests__/vega_visualization.js b/src/legacy/core_plugins/vis_type_vega/public/__tests__/vega_visualization.js index 5befc09b24544..0db3e6cefa724 100644 --- a/src/legacy/core_plugins/vis_type_vega/public/__tests__/vega_visualization.js +++ b/src/legacy/core_plugins/vis_type_vega/public/__tests__/vega_visualization.js @@ -22,7 +22,6 @@ import expect from '@kbn/expect'; import ngMock from 'ng_mock'; import $ from 'jquery'; import { createVegaVisualization } from '../vega_visualization'; -import LogstashIndexPatternStubProvider from 'fixtures/stubbed_logstash_index_pattern'; import { ImageComparator } from 'test_utils/image_comparator'; import vegaliteGraph from '!!raw-loader!./vegalite_graph.hjson'; @@ -57,7 +56,6 @@ const PIXEL_DIFF = 30; describe('VegaVisualizations', () => { let domNode; let VegaVisualization; - let indexPattern; let vis; let imageComparator; let vegaVisualizationDependencies; @@ -71,7 +69,7 @@ describe('VegaVisualizations', () => { beforeEach(ngMock.module('kibana')); beforeEach( - ngMock.inject((Private, $injector) => { + ngMock.inject($injector => { vegaVisualizationDependencies = { serviceSettings: $injector.get('serviceSettings'), core: { @@ -99,7 +97,6 @@ describe('VegaVisualizations', () => { } VegaVisualization = createVegaVisualization(vegaVisualizationDependencies); - indexPattern = Private(LogstashIndexPatternStubProvider); }) ); @@ -108,7 +105,7 @@ describe('VegaVisualizations', () => { setupDOM('512px', '512px'); imageComparator = new ImageComparator(); - vis = visualizationsStart.createVis(indexPattern, { type: 'vega' }); + vis = visualizationsStart.createVis('vega', { type: 'vega' }); }); afterEach(function() { diff --git a/src/legacy/core_plugins/vis_type_vega/public/vega_type.ts b/src/legacy/core_plugins/vis_type_vega/public/vega_type.ts index 78f9c170ab62d..b0ec90d2c378f 100644 --- a/src/legacy/core_plugins/vis_type_vega/public/vega_type.ts +++ b/src/legacy/core_plugins/vis_type_vega/public/vega_type.ts @@ -19,7 +19,6 @@ import { i18n } from '@kbn/i18n'; // @ts-ignore -import { Status } from '../../visualizations/public'; import { DefaultEditorSize } from '../../vis_default_editor/public'; import { VegaVisualizationDependencies } from './plugin'; import { VegaVisEditor } from './components'; @@ -51,7 +50,6 @@ export const createVegaTypeDefinition = (dependencies: VegaVisualizationDependen }, visualization, requestHandler, - requiresUpdateStatus: [Status.DATA, Status.RESIZE], responseHandler: 'none', options: { showIndexSelection: false, diff --git a/src/legacy/core_plugins/vis_type_vega/public/vega_visualization.js b/src/legacy/core_plugins/vis_type_vega/public/vega_visualization.js index 3d48eeaaf3f94..96835ef3b10bc 100644 --- a/src/legacy/core_plugins/vis_type_vega/public/vega_visualization.js +++ b/src/legacy/core_plugins/vis_type_vega/public/vega_visualization.js @@ -69,7 +69,7 @@ export const createVegaVisualization = ({ serviceSettings }) => * @param {*} status * @returns {Promise<void>} */ - async render(visData, visParams, status) { + async render(visData) { const { toasts } = getNotifications(); if (!visData && !this._vegaView) { @@ -82,7 +82,7 @@ export const createVegaVisualization = ({ serviceSettings }) => } try { - await this._render(visData, status); + await this._render(visData); } catch (error) { if (this._vegaView) { this._vegaView.onError(error); @@ -96,8 +96,8 @@ export const createVegaVisualization = ({ serviceSettings }) => } } - async _render(vegaParser, status) { - if (vegaParser && (status.data || !this._vegaView)) { + async _render(vegaParser) { + if (vegaParser) { // New data received, rebuild the graph if (this._vegaView) { await this._vegaView.destroy(); @@ -121,9 +121,6 @@ export const createVegaVisualization = ({ serviceSettings }) => this._vegaView = new VegaView(vegaViewParams); } await this._vegaView.init(); - } else if (status.resize) { - // the graph has been resized - await this._vegaView.resize(); } } diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/__snapshots__/index.test.tsx.snap b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/__snapshots__/index.test.tsx.snap index 80ed3b6a7ebff..442bc826d51fc 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/__snapshots__/index.test.tsx.snap +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/__snapshots__/index.test.tsx.snap @@ -54,7 +54,7 @@ exports[`MetricsAxisOptions component should init with the default set of props } vis={ Object { - "setVisType": [MockFunction], + "setState": [MockFunction], "type": Object { "schemas": Object { "metrics": Array [ @@ -126,7 +126,7 @@ exports[`MetricsAxisOptions component should init with the default set of props } vis={ Object { - "setVisType": [MockFunction], + "setState": [MockFunction], "type": Object { "schemas": Object { "metrics": Array [ @@ -169,7 +169,7 @@ exports[`MetricsAxisOptions component should init with the default set of props setCategoryAxis={[Function]} vis={ Object { - "setVisType": [MockFunction], + "setState": [MockFunction], "type": Object { "schemas": Object { "metrics": Array [ diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/index.test.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/index.test.tsx index 032dd10cf11d2..a3f150e718817 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/index.test.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/index.test.tsx @@ -94,7 +94,7 @@ describe('MetricsAxisOptions component', () => { type: ChartTypes.AREA, schemas: { metrics: [{ name: 'metric' }] }, }, - setVisType: jest.fn(), + setState: jest.fn(), }, stateParams: { valueAxes: [axis], @@ -145,7 +145,7 @@ describe('MetricsAxisOptions component', () => { }, }); - expect(defaultProps.vis.setVisType).toHaveBeenLastCalledWith(ChartTypes.LINE); + expect(defaultProps.vis.setState).toHaveBeenLastCalledWith({ type: ChartTypes.LINE }); }); it('should set histogram visType when multiple seriesParam', () => { @@ -159,7 +159,7 @@ describe('MetricsAxisOptions component', () => { }, }); - expect(defaultProps.vis.setVisType).toHaveBeenLastCalledWith(ChartTypes.HISTOGRAM); + expect(defaultProps.vis.setState).toHaveBeenLastCalledWith({ type: ChartTypes.HISTOGRAM }); }); }); diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/index.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/index.tsx index a6f4a967d9c76..c7b4562b1087e 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/index.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/metrics_axes/index.tsx @@ -299,7 +299,7 @@ function MetricsAxisOptions(props: ValidationVisOptionsProps<BasicVislibParams>) }, [stateParams.seriesParams]); useEffect(() => { - vis.setVisType(visType); + vis.setState({ type: visType } as any); }, [vis, visType]); return isTabSelected ? ( diff --git a/src/legacy/core_plugins/vis_type_vislib/public/components/options/point_series/point_series.tsx b/src/legacy/core_plugins/vis_type_vislib/public/components/options/point_series/point_series.tsx index 229c4922145e8..b9872ab94bd0b 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/components/options/point_series/point_series.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/components/options/point_series/point_series.tsx @@ -44,7 +44,9 @@ function PointSeriesOptions(props: ValidationVisOptionsProps<BasicVislibParams>) <BasicOptions {...props} /> - {vis.hasSchemaAgg('segment', 'date_histogram') ? ( + {vis.data.aggs!.aggs.some( + agg => agg.schema === 'segment' && agg.type.name === 'date_histogram' + ) ? ( <SwitchOption label={i18n.translate('visTypeVislib.editors.pointSeries.currentTimeMarkerLabel', { defaultMessage: 'Current time marker', diff --git a/src/legacy/core_plugins/vis_type_vislib/public/vis_controller.tsx b/src/legacy/core_plugins/vis_type_vislib/public/vis_controller.tsx index 580e47195aada..010b61a0900b0 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/vis_controller.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/vis_controller.tsx @@ -26,7 +26,8 @@ import { Positions } from './utils/collections'; import { VisTypeVislibDependencies } from './plugin'; import { mountReactNode } from '../../../../core/public/utils'; import { VisLegend, CUSTOM_LEGEND_VIS_TYPES } from './vislib/components/legend'; -import { VisParams, Vis } from '../../visualizations/public'; +import { VisParams } from '../../visualizations/public'; +import { ExprVis } from '../../visualizations/public/np_ready/public/expressions/vis'; const legendClassName = { top: 'visLib--legend-top', @@ -45,7 +46,7 @@ export const createVislibVisController = (deps: VisTypeVislibDependencies) => { legendEl: HTMLDivElement; vislibVis: any; - constructor(public el: Element, public vis: Vis) { + constructor(public el: Element, public vis: ExprVis) { this.el = el; this.vis = vis; this.unmount = null; diff --git a/src/legacy/core_plugins/vis_type_vislib/public/vislib/__tests__/visualizations/pie_chart.js b/src/legacy/core_plugins/vis_type_vislib/public/vislib/__tests__/visualizations/pie_chart.js index 43e3b987f1962..21f4e60e4bc6e 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/vislib/__tests__/visualizations/pie_chart.js +++ b/src/legacy/core_plugins/vis_type_vislib/public/vislib/__tests__/visualizations/pie_chart.js @@ -133,21 +133,30 @@ describe('No global chart settings', function() { responseHandler = vislibSlicesResponseHandler; let id1 = 1; - stubVis1 = visualizationsStart.createVis(indexPattern, { + stubVis1 = visualizationsStart.createVis('pie', { type: 'pie', - aggs: rowAgg, + data: { + aggs: rowAgg, + searchSource: { + getField: name => { + if (name === 'index') { + return indexPattern; + } + }, + }, + }, }); stubVis1.isHierarchical = () => true; // We need to set the aggs to a known value. - _.each(stubVis1.aggs.aggs, function(agg) { + _.each(stubVis1.data.aggs.aggs, function(agg) { agg.id = 'agg_' + id1++; }); }); beforeEach(async () => { - const table1 = tabifyAggResponse(stubVis1.aggs, threeTermBuckets, { + const table1 = tabifyAggResponse(stubVis1.data.aggs, threeTermBuckets, { metricsAtAllLevels: true, }); data1 = await responseHandler(table1, rowAggDimensions); @@ -222,19 +231,28 @@ describe('Vislib PieChart Class Test Suite', function() { responseHandler = vislibSlicesResponseHandler; let id = 1; - stubVis = visualizationsStart.createVis(indexPattern, { + stubVis = visualizationsStart.createVis('pie', { type: 'pie', - aggs: dataAgg, + data: { + aggs: dataAgg, + searchSource: { + getField: name => { + if (name === 'index') { + return indexPattern; + } + }, + }, + }, }); // We need to set the aggs to a known value. - _.each(stubVis.aggs.aggs, function(agg) { + _.each(stubVis.data.aggs.aggs, function(agg) { agg.id = 'agg_' + id++; }); }); beforeEach(async () => { - const table = tabifyAggResponse(stubVis.aggs, threeTermBuckets, { + const table = tabifyAggResponse(stubVis.data.aggs, threeTermBuckets, { metricsAtAllLevels: true, }); data = await responseHandler(table, dataDimensions); diff --git a/src/legacy/core_plugins/vis_type_vislib/public/vislib/components/legend/legend.tsx b/src/legacy/core_plugins/vis_type_vislib/public/vislib/components/legend/legend.tsx index d82941b7b8cee..afd974d6d9b40 100644 --- a/src/legacy/core_plugins/vis_type_vislib/public/vislib/components/legend/legend.tsx +++ b/src/legacy/core_plugins/vis_type_vislib/public/vislib/components/legend/legend.tsx @@ -23,22 +23,11 @@ import { compact, uniq, map, every, isUndefined } from 'lodash'; import { i18n } from '@kbn/i18n'; import { EuiPopoverProps, EuiIcon, keyCodes, htmlIdGenerator } from '@elastic/eui'; -import { IAggConfig } from '../../../../../../../plugins/data/public'; +import { createFiltersFromEvent } from '../../../legacy_imports'; import { CUSTOM_LEGEND_VIS_TYPES, LegendItem } from './models'; import { VisLegendItem } from './legend_item'; import { getPieNames } from './pie_utils'; -import { Vis } from '../../../../../visualizations/public'; -import { createFiltersFromEvent, tabifyGetColumns } from '../../../legacy_imports'; - -const getTableAggs = (vis: Vis): IAggConfig[] => { - if (!vis.aggs || !vis.aggs.getResponseAggs) { - return []; - } - const columns = tabifyGetColumns(vis.aggs.getResponseAggs(), !vis.isHierarchical()); - return columns.map(c => c.aggConfig); -}; - export interface VisLegendProps { vis: any; vislibVis: any; @@ -50,7 +39,6 @@ export interface VisLegendProps { export interface VisLegendState { open: boolean; labels: any[]; - tableAggs: any[]; filterableLabels: Set<string>; selectedLabel: string | null; } @@ -66,7 +54,6 @@ export class VisLegend extends PureComponent<VisLegendProps, VisLegendState> { this.state = { open, labels: [], - tableAggs: [], filterableLabels: new Set(), selectedLabel: null, }; @@ -200,7 +187,6 @@ export class VisLegend extends PureComponent<VisLegendProps, VisLegendState> { this.getColor = this.props.vislibVis.visConfig.data.getColorFunc(); } - this.setState({ tableAggs: getTableAggs(this.props.vis) }); this.setLabels(this.props.visData, vislibVis.visConfigArgs.type); }; diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/components/visualization.test.js b/src/legacy/core_plugins/visualizations/public/np_ready/public/components/visualization.test.js index 4773fa482e62d..f2844e3aab113 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/components/visualization.test.js +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/components/visualization.test.js @@ -55,7 +55,7 @@ describe('<Visualization/>', () => { beforeEach(() => { vis = { - _setUiState: function(uiState) { + setUiState: function(uiState) { this.uiState = uiState; }, getUiState: function() { @@ -79,15 +79,6 @@ describe('<Visualization/>', () => { expect(wrapper.text()).toBe('No results found'); }); - it('should display error message when there is a request error that should be shown and no data', () => { - const errorVis = { ...vis, requestError: { message: 'Request error' }, showRequestError: true }; - const data = null; - const wrapper = render( - <Visualization vis={errorVis} visData={data} listenOnChange={true} uiState={uiState} /> - ); - expect(wrapper.text()).toBe('Request error'); - }); - it('should render chart when data is present', () => { const wrapper = render( <Visualization vis={vis} visData={visData} uiState={uiState} listenOnChange={true} /> diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/components/visualization.tsx b/src/legacy/core_plugins/visualizations/public/np_ready/public/components/visualization.tsx index 33830c45848e4..5296de365daec 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/components/visualization.tsx +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/components/visualization.tsx @@ -23,10 +23,9 @@ import { PersistedState } from '../../../../../../../plugins/visualizations/publ import { memoizeLast } from '../legacy/memoize'; import { VisualizationChart } from './visualization_chart'; import { VisualizationNoResults } from './visualization_noresults'; -import { VisualizationRequestError } from './visualization_requesterror'; -import { Vis } from '..'; +import { ExprVis } from '../expressions/vis'; -function shouldShowNoResultsMessage(vis: Vis, visData: any): boolean { +function shouldShowNoResultsMessage(vis: ExprVis, visData: any): boolean { const requiresSearch = get(vis, 'type.requiresSearch'); const rows: object[] | undefined = get(visData, 'rows'); const isZeroHits = get(visData, 'hits') === 0 || (rows && !rows.length); @@ -35,17 +34,11 @@ function shouldShowNoResultsMessage(vis: Vis, visData: any): boolean { return Boolean(requiresSearch && isZeroHits && shouldShowMessage); } -function shouldShowRequestErrorMessage(vis: Vis, visData: any): boolean { - const requestError = get(vis, 'requestError'); - const showRequestError = get(vis, 'showRequestError'); - return Boolean(!visData && requestError && showRequestError); -} - interface VisualizationProps { listenOnChange: boolean; onInit?: () => void; uiState: PersistedState; - vis: Vis; + vis: ExprVis; visData: any; visParams: any; } @@ -56,20 +49,17 @@ export class Visualization extends React.Component<VisualizationProps> { constructor(props: VisualizationProps) { super(props); - props.vis._setUiState(props.uiState); + props.vis.setUiState(props.uiState); } public render() { const { vis, visData, visParams, onInit, uiState, listenOnChange } = this.props; const noResults = this.showNoResultsMessage(vis, visData); - const requestError = shouldShowRequestErrorMessage(vis, visData); return ( <div className="visualization"> - {requestError ? ( - <VisualizationRequestError onInit={onInit} error={vis.requestError} /> - ) : noResults ? ( + {noResults ? ( <VisualizationNoResults onInit={onInit} /> ) : ( <VisualizationChart diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/components/visualization_chart.tsx b/src/legacy/core_plugins/visualizations/public/np_ready/public/components/visualization_chart.tsx index 7b1a18e8066a7..fcfbc8445952c 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/components/visualization_chart.tsx +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/components/visualization_chart.tsx @@ -21,14 +21,14 @@ import React from 'react'; import * as Rx from 'rxjs'; import { debounceTime, filter, share, switchMap } from 'rxjs/operators'; import { PersistedState } from '../../../../../../../plugins/visualizations/public'; -import { Vis, VisualizationController } from '../vis'; -import { getUpdateStatus } from '../legacy/update_status'; +import { VisualizationController } from '../types'; import { ResizeChecker } from '../../../../../../../plugins/kibana_utils/public'; +import { ExprVis } from '../expressions/vis'; interface VisualizationChartProps { onInit?: () => void; uiState: PersistedState; - vis: Vis; + vis: ExprVis; visData: any; visParams: any; listenOnChange: boolean; @@ -40,10 +40,9 @@ class VisualizationChart extends React.Component<VisualizationChartProps> { private chartDiv = React.createRef<HTMLDivElement>(); private containerDiv = React.createRef<HTMLDivElement>(); private renderSubject: Rx.Subject<{ - vis: Vis; + vis: ExprVis; visParams: any; visData: any; - container: HTMLElement; }>; private renderSubscription: Rx.Subscription; @@ -54,11 +53,9 @@ class VisualizationChart extends React.Component<VisualizationChartProps> { const render$ = this.renderSubject.asObservable().pipe(share()); const success$ = render$.pipe( - filter( - ({ vis, visData, container }) => vis && container && (!vis.type.requiresSearch || visData) - ), + filter(({ vis, visData }) => vis && (!vis.type.requiresSearch || visData)), debounceTime(100), - switchMap(async ({ vis, visData, visParams, container }) => { + switchMap(async ({ vis, visData, visParams }) => { if (!this.visualization) { // This should never happen, since we only should trigger another rendering // after this component has mounted and thus the visualization implementation @@ -66,15 +63,11 @@ class VisualizationChart extends React.Component<VisualizationChartProps> { throw new Error('Visualization implementation was not initialized on first render.'); } - vis.size = [container.clientWidth, container.clientHeight]; - const status = getUpdateStatus(vis.type.requiresUpdateStatus, this, this.props); - return this.visualization.render(visData, visParams, status); + return this.visualization.render(visData, visParams); }) ); - const requestError$ = render$.pipe(filter(({ vis }) => vis.requestError)); - - this.renderSubscription = Rx.merge(success$, requestError$).subscribe(() => { + this.renderSubscription = success$.subscribe(() => { if (this.props.onInit) { this.props.onInit(); } @@ -145,7 +138,6 @@ class VisualizationChart extends React.Component<VisualizationChartProps> { vis: this.props.vis, visData: this.props.visData, visParams: this.props.visParams, - container: this.containerDiv.current, }); } } diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/get_index_pattern.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/get_index_pattern.ts index 51d839275fd27..05ce68221eaf0 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/get_index_pattern.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/get_index_pattern.ts @@ -28,18 +28,18 @@ import { getUISettings, getSavedObjects } from '../services'; export async function getIndexPattern( savedVis: VisSavedObject ): Promise<IIndexPattern | undefined> { - if (savedVis.vis.type.name !== 'metrics') { - return savedVis.vis.indexPattern; + if (savedVis.visState.type !== 'metrics') { + return savedVis.searchSource!.getField('index'); } const savedObjectsClient = getSavedObjects().client; const defaultIndex = getUISettings().get('defaultIndex'); - if (savedVis.vis.params.index_pattern) { + if (savedVis.visState.params.index_pattern) { const indexPatternObjects = await savedObjectsClient.find<IndexPatternAttributes>({ type: 'index-pattern', fields: ['title', 'fields'], - search: `"${savedVis.vis.params.index_pattern}"`, + search: `"${savedVis.visState.params.index_pattern}"`, searchFields: ['title'], }); const [indexPattern] = indexPatternObjects.savedObjects.map(indexPatterns.getFromSavedObject); diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts index c45e6832dc836..342824bade3dd 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts @@ -45,13 +45,12 @@ import { PersistedState } from '../../../../../../../plugins/visualizations/publ import { buildPipeline } from '../legacy/build_pipeline'; import { Vis } from '../vis'; import { getExpressions, getUiActions } from '../services'; -import { VisSavedObject } from '../types'; import { VIS_EVENT_TO_TRIGGER } from './events'; const getKeys = <T extends {}>(o: T): Array<keyof T> => Object.keys(o) as Array<keyof T>; export interface VisualizeEmbeddableConfiguration { - savedVisualization: VisSavedObject; + vis: Vis; indexPatterns?: IIndexPattern[]; editUrl: string; editable: boolean; @@ -73,7 +72,6 @@ export interface VisualizeInput extends EmbeddableInput { export interface VisualizeOutput extends EmbeddableOutput { editUrl: string; indexPatterns?: IIndexPattern[]; - savedObjectId: string; visTypeName: string; } @@ -81,9 +79,6 @@ type ExpressionLoader = InstanceType<ExpressionsStart['ExpressionLoader']>; export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOutput> { private handler?: ExpressionLoader; - private savedVisualization: VisSavedObject; - private appState: { save(): void } | undefined; - private uiState: PersistedState; private timefilter: TimefilterContract; private timeRange?: TimeRange; private query?: Query; @@ -99,49 +94,24 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut constructor( timefilter: TimefilterContract, - { - savedVisualization, - editUrl, - indexPatterns, - editable, - appState, - uiState, - }: VisualizeEmbeddableConfiguration, + { vis, editUrl, indexPatterns, editable }: VisualizeEmbeddableConfiguration, initialInput: VisualizeInput, parent?: Container ) { super( initialInput, { - defaultTitle: savedVisualization.title, + defaultTitle: vis.title, editUrl, indexPatterns, editable, - savedObjectId: savedVisualization.id!, - visTypeName: savedVisualization.vis.type.name, + visTypeName: vis.type.name, }, parent ); this.timefilter = timefilter; - this.appState = appState; - this.savedVisualization = savedVisualization; - this.vis = this.savedVisualization.vis; - - this.vis.on('update', this.handleVisUpdate); - this.vis.on('reload', this.reload); - - if (uiState) { - this.uiState = uiState; - } else { - const parsedUiState = savedVisualization.uiStateJSON - ? JSON.parse(savedVisualization.uiStateJSON) - : {}; - this.uiState = new PersistedState(parsedUiState); - - this.uiState.on('change', this.uiStateChangeHandler); - } - - this.vis._setUiState(this.uiState); + this.vis = vis; + this.vis.uiState.on('change', this.uiStateChangeHandler); this.autoRefreshFetchSubscription = timefilter .getAutoRefreshFetch$() @@ -155,7 +125,7 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut } public getVisualizationDescription() { - return this.savedVisualization.description; + return this.vis.description; } public getInspectorAdapters = () => { @@ -184,16 +154,16 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut if (!_.isEqual(visCustomizations, this.visCustomizations)) { this.visCustomizations = visCustomizations; // Turn this off or the uiStateChangeHandler will fire for every modification. - this.uiState.off('change', this.uiStateChangeHandler); - this.uiState.clearAllKeys(); - this.uiState.set('vis', visCustomizations); + this.vis.uiState.off('change', this.uiStateChangeHandler); + this.vis.uiState.clearAllKeys(); + this.vis.uiState.set('vis', visCustomizations); getKeys(visCustomizations).forEach(key => { - this.uiState.set(key, visCustomizations[key]); + this.vis.uiState.set(key, visCustomizations[key]); }); - this.uiState.on('change', this.uiStateChangeHandler); + this.vis.uiState.on('change', this.uiStateChangeHandler); } - } else if (!this.appState) { - this.uiState.clearAllKeys(); + } else if (this.parent) { + this.vis.uiState.clearAllKeys(); } } @@ -227,8 +197,8 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut } } - if (this.savedVisualization.description && this.domNode) { - this.domNode.setAttribute('data-description', this.savedVisualization.description); + if (this.vis.description && this.domNode) { + this.domNode.setAttribute('data-description', this.vis.description); } if (this.handler && dirty) { @@ -236,6 +206,22 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut } } + // this is a hack to make editor still work, will be removed once we clean up editor + // @ts-ignore + hasInspector = () => { + const visTypesWithoutInspector = [ + 'markdown', + 'input_control_vis', + 'metrics', + 'vega', + 'timelion', + ]; + if (visTypesWithoutInspector.includes(this.vis.type.name)) { + return false; + } + return this.getInspectorAdapters(); + }; + /** * * @param {Element} domNode @@ -245,26 +231,6 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut this.transferCustomizationsToUiState(); - this.savedVisualization.vis._setUiState(this.uiState); - this.uiState = this.savedVisualization.vis.getUiState(); - - // this is a hack to make editor still work, will be removed once we clean up editor - this.vis.hasInspector = () => { - const visTypesWithoutInspector = [ - 'markdown', - 'input_control_vis', - 'metrics', - 'vega', - 'timelion', - ]; - if (visTypesWithoutInspector.includes(this.vis.type.name)) { - return false; - } - return this.getInspectorAdapters(); - }; - - this.vis.openInspector = this.openInspector; - const div = document.createElement('div'); div.className = `visualize panel-content panel-content--fullWidth`; domNode.appendChild(div); @@ -277,12 +243,12 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut this.handler.events$.subscribe(async event => { // maps hack, remove once esaggs function is cleaned up and ready to accept variables if (event.name === 'bounds') { - const agg = this.vis.getAggConfig().aggs.find((a: any) => { + const agg = this.vis.data.aggs!.aggs.find((a: any) => { return get(a, 'type.dslName') === 'geohash_grid'; }); if ( - agg.params.precision !== event.data.precision || - !_.isEqual(agg.params.boundingBox, event.data.boundingBox) + (agg && agg.params.precision !== event.data.precision) || + (agg && !_.isEqual(agg.params.boundingBox, event.data.boundingBox)) ) { agg.params.boundingBox = event.data.boundingBox; agg.params.precision = event.data.precision; @@ -296,7 +262,7 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut event.name === 'brush' ? VIS_EVENT_TO_TRIGGER.brush : VIS_EVENT_TO_TRIGGER.filter; const context: EmbeddableVisTriggerContext = { embeddable: this, - timeFieldName: this.vis.indexPattern.timeFieldName, + timeFieldName: this.vis.data.indexPattern!.timeFieldName!, data: event.data, }; getUiActions() @@ -308,8 +274,8 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut div.setAttribute('data-title', this.output.title || ''); - if (this.savedVisualization.description) { - div.setAttribute('data-description', this.savedVisualization.description); + if (this.vis.description) { + div.setAttribute('data-description', this.vis.description); } div.setAttribute('data-test-subj', 'visualizationLoader'); @@ -339,10 +305,8 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut public destroy() { super.destroy(); this.subscriptions.forEach(s => s.unsubscribe()); - this.uiState.off('change', this.uiStateChangeHandler); - this.savedVisualization.vis.removeListener('reload', this.reload); - this.savedVisualization.vis.removeListener('update', this.handleVisUpdate); - this.savedVisualization.destroy(); + this.vis.uiState.off('change', this.uiStateChangeHandler); + if (this.handler) { this.handler.destroy(); this.handler.getElement().remove(); @@ -361,35 +325,25 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut query: this.input.query, filters: this.input.filters, }, - uiState: this.uiState, + uiState: this.vis.uiState, }; this.expression = await buildPipeline(this.vis, { - searchSource: this.savedVisualization.searchSource, timefilter: this.timefilter, timeRange: this.timeRange, - savedObjectId: this.savedVisualization.id, }); - this.vis.filters = { timeRange: this.timeRange }; - if (this.handler) { this.handler.update(this.expression, expressionParams); } - - this.vis.emit('apply'); } private handleVisUpdate = async () => { - if (this.appState) { - this.appState.save(); - } - this.updateHandler(); }; private uiStateChangeHandler = () => { this.updateInput({ - ...this.uiState.toJSON(), + ...this.vis.uiState.toJSON(), }); }; diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable_factory.tsx b/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable_factory.tsx index 1cd97115ee10e..911f5530e97e3 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable_factory.tsx @@ -26,9 +26,8 @@ import { ErrorEmbeddable, } from '../../../../../../../plugins/embeddable/public'; import { DisabledLabEmbeddable } from './disabled_lab_embeddable'; -import { getIndexPattern } from './get_index_pattern'; import { VisualizeEmbeddable, VisualizeInput, VisualizeOutput } from './visualize_embeddable'; -import { VisSavedObject } from '../types'; +import { Vis } from '../types'; import { VISUALIZE_EMBEDDABLE_TYPE } from './constants'; import { getCapabilities, @@ -39,6 +38,7 @@ import { getTimeFilter, } from '../services'; import { showNewVisModal } from '../wizard'; +import { convertToSerializedVis } from '../saved_visualizations/_saved_vis'; interface VisualizationAttributes extends SavedObjectAttributes { visState: string; @@ -94,31 +94,31 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory< } public async createFromObject( - savedObject: VisSavedObject, + vis: Vis, input: Partial<VisualizeInput> & { id: string }, parent?: Container ): Promise<VisualizeEmbeddable | ErrorEmbeddable | DisabledLabEmbeddable> { const savedVisualizations = getSavedVisualizationsLoader(); try { - const visId = savedObject.id as string; + const visId = vis.id as string; const editUrl = visId ? getHttp().basePath.prepend(`/app/kibana${savedVisualizations.urlFor(visId)}`) : ''; const isLabsEnabled = getUISettings().get<boolean>('visualize:enableLabs'); - if (!isLabsEnabled && savedObject.vis.type.stage === 'experimental') { - return new DisabledLabEmbeddable(savedObject.title, input); + if (!isLabsEnabled && vis.type.stage === 'experimental') { + return new DisabledLabEmbeddable(vis.title, input); } - const indexPattern = await getIndexPattern(savedObject); + const indexPattern = vis.data.indexPattern; const indexPatterns = indexPattern ? [indexPattern] : []; const editable = await this.isEditable(); return new VisualizeEmbeddable( getTimeFilter(), { - savedVisualization: savedObject, + vis, indexPatterns, editUrl, editable, @@ -143,7 +143,8 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory< try { const savedObject = await savedVisualizations.get(savedObjectId); - return this.createFromObject(savedObject, input, parent); + const vis = new Vis(savedObject.visState.type, await convertToSerializedVis(savedObject)); + return this.createFromObject(vis, input, parent); } catch (e) { console.error(e); // eslint-disable-line no-console return new ErrorEmbeddable(e, input, parent); diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/expressions/vis.js b/src/legacy/core_plugins/visualizations/public/np_ready/public/expressions/vis.ts similarity index 67% rename from src/legacy/core_plugins/visualizations/public/np_ready/public/expressions/vis.js rename to src/legacy/core_plugins/visualizations/public/np_ready/public/expressions/vis.ts index a891140677d60..3b0458a6c8dcc 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/expressions/vis.js +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/expressions/vis.ts @@ -32,25 +32,47 @@ import _ from 'lodash'; import { PersistedState } from '../../../../../../../plugins/visualizations/public'; import { getTypes } from '../services'; +import { VisType } from '../vis_types'; +import { VisParams } from '../types'; -export class Vis extends EventEmitter { - constructor(visState = { type: 'histogram' }) { +export interface ExprVisState { + title?: string; + type: VisType | string; + params?: VisParams; +} + +export interface ExprVisAPIEvents { + filter: (data: any) => void; + brush: (data: any) => void; +} + +export interface ExprVisAPI { + events: ExprVisAPIEvents; +} + +export class ExprVis extends EventEmitter { + public title: string = ''; + public type: VisType; + public params: VisParams = {}; + public sessionState: Record<string, any> = {}; + public API: ExprVisAPI; + public eventsSubject: any; + private uiState: PersistedState; + + constructor(visState: ExprVisState = { type: 'histogram' }) { super(); - this._setUiState(new PersistedState()); + this.type = this.getType(visState.type); + this.uiState = new PersistedState(); this.setState(visState); - // Session state is for storing information that is transitory, and will not be saved with the visualization. - // For instance, map bounds, which depends on the view port, browser window size, etc. - this.sessionState = {}; - this.API = { events: { - filter: data => { + filter: (data: any) => { if (!this.eventsSubject) return; this.eventsSubject.next({ name: 'filterBucket', data }); }, - brush: data => { + brush: (data: any) => { if (!this.eventsSubject) return; this.eventsSubject.next({ name: 'brush', data }); }, @@ -58,18 +80,22 @@ export class Vis extends EventEmitter { }; } - setState(state) { - this.title = state.title || ''; - const type = state.type || this.type; + private getType(type: string | VisType) { if (_.isString(type)) { - this.type = getTypes().get(type); + return getTypes().get(type); if (!this.type) { throw new Error(`Invalid type "${type}"`); } } else { - this.type = type; + return type; } + } + setState(state: ExprVisState) { + this.title = state.title || ''; + if (state.type) { + this.type = this.getType(state.type); + } this.params = _.defaultsDeep( {}, _.cloneDeep(state.params || {}), @@ -77,10 +103,6 @@ export class Vis extends EventEmitter { ); } - setCurrentState(state) { - this.setState(state); - } - getState() { return { title: this.title, @@ -106,34 +128,27 @@ export class Vis extends EventEmitter { } hasUiState() { - return !!this.__uiState; + return !!this.uiState; } - /*** - * this should not be used outside of visualize - * @param uiState - * @private - */ - _setUiState(uiState) { - if (uiState instanceof PersistedState) { - this.__uiState = uiState; - } + getUiState() { + return this.uiState; } - getUiState() { - return this.__uiState; + setUiState(state: PersistedState) { + this.uiState = state; } /** * Currently this is only used to extract map-specific information * (e.g. mapZoom, mapCenter). */ - uiStateVal(key, val) { + uiStateVal(key: string, val: any) { if (this.hasUiState()) { if (_.isUndefined(val)) { - return this.__uiState.get(key); + return this.uiState.get(key); } - return this.__uiState.set(key, val); + return this.uiState.set(key, val); } return val; } diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/expressions/visualization_renderer.tsx b/src/legacy/core_plugins/visualizations/public/np_ready/public/expressions/visualization_renderer.tsx index 02a31447d23c1..0fd81c753da24 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/expressions/visualization_renderer.tsx +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/expressions/visualization_renderer.tsx @@ -20,8 +20,9 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; // @ts-ignore -import { Vis } from './vis'; +import { ExprVis } from './vis'; import { Visualization } from '../components'; +import { VisParams } from '../types'; export const visualization = () => ({ name: 'visualization', @@ -31,9 +32,9 @@ export const visualization = () => ({ const { visData, visConfig, params } = config; const visType = config.visType || visConfig.type; - const vis = new Vis({ - type: visType, - params: visConfig, + const vis = new ExprVis({ + type: visType as string, + params: visConfig as VisParams, }); vis.eventsSubject = { next: handlers.event }; diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/index.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/index.ts index b59eb2277411c..078cc4a3f4035 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/index.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/index.ts @@ -39,12 +39,11 @@ export { VisualizationsSetup, VisualizationsStart }; /** @public types */ export { VisTypeAlias, VisType } from './vis_types'; export { VisSavedObject } from './types'; -export { Vis, VisParams, VisState } from './vis'; +export { Vis, VisParams, SerializedVis, SerializedVisData, VisData } from './vis'; import { VisualizeEmbeddableFactory, VisualizeEmbeddable } from './embeddable'; export type VisualizeEmbeddableFactoryContract = PublicContract<VisualizeEmbeddableFactory>; export type VisualizeEmbeddableContract = PublicContract<VisualizeEmbeddable>; export { TypesService } from './vis_types/types_service'; -export { Status } from './legacy/update_status'; // should remove export { VISUALIZE_EMBEDDABLE_TYPE, VisualizeInput } from './embeddable'; export { SchemaConfig } from './legacy/build_pipeline'; diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.test.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.test.ts index 9446069182e19..d5c532b53a53e 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.test.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.test.ts @@ -27,7 +27,7 @@ import { Schemas, } from './build_pipeline'; import { Vis } from '..'; -import { searchSourceMock, dataPluginMock } from '../../../../../../../plugins/data/public/mocks'; +import { dataPluginMock } from '../../../../../../../plugins/data/public/mocks'; import { IAggConfig } from '../../../../../../../plugins/data/public'; jest.mock('ui/new_platform'); @@ -78,19 +78,11 @@ describe('visualize loader pipeline helpers: build pipeline', () => { }); describe('buildPipelineVisFunction', () => { - let visStateDef: ReturnType<Vis['getCurrentState']>; let schemaConfig: SchemaConfig; let schemasDef: Schemas; let uiState: any; beforeEach(() => { - visStateDef = { - title: 'title', - // @ts-ignore - type: 'type', - params: {}, - } as ReturnType<Vis['getCurrentState']>; - schemaConfig = { accessor: 0, label: '', @@ -105,66 +97,53 @@ describe('visualize loader pipeline helpers: build pipeline', () => { it('handles vega function', () => { const vis = { - ...visStateDef, params: { spec: 'this is a test' }, }; - const actual = buildPipelineVisFunction.vega(vis, schemasDef, uiState); + const actual = buildPipelineVisFunction.vega(vis.params, schemasDef, uiState); expect(actual).toMatchSnapshot(); }); it('handles input_control_vis function', () => { - const visState = { - ...visStateDef, - params: { - some: 'nested', - data: { here: true }, - }, + const params = { + some: 'nested', + data: { here: true }, }; - const actual = buildPipelineVisFunction.input_control_vis(visState, schemasDef, uiState); + const actual = buildPipelineVisFunction.input_control_vis(params, schemasDef, uiState); expect(actual).toMatchSnapshot(); }); it('handles metrics/tsvb function', () => { - const visState = { ...visStateDef, params: { foo: 'bar' } }; - const actual = buildPipelineVisFunction.metrics(visState, schemasDef, uiState); + const params = { foo: 'bar' }; + const actual = buildPipelineVisFunction.metrics(params, schemasDef, uiState); expect(actual).toMatchSnapshot(); }); it('handles timelion function', () => { - const visState = { - ...visStateDef, - params: { expression: 'foo', interval: 'bar' }, - }; - const actual = buildPipelineVisFunction.timelion(visState, schemasDef, uiState); + const params = { expression: 'foo', interval: 'bar' }; + const actual = buildPipelineVisFunction.timelion(params, schemasDef, uiState); expect(actual).toMatchSnapshot(); }); it('handles markdown function', () => { - const visState = { - ...visStateDef, - params: { - markdown: '## hello _markdown_', - fontSize: 12, - openLinksInNewTab: true, - foo: 'bar', - }, + const params = { + markdown: '## hello _markdown_', + fontSize: 12, + openLinksInNewTab: true, + foo: 'bar', }; - const actual = buildPipelineVisFunction.markdown(visState, schemasDef, uiState); + const actual = buildPipelineVisFunction.markdown(params, schemasDef, uiState); expect(actual).toMatchSnapshot(); }); it('handles undefined markdown function', () => { - const visState = { - ...visStateDef, - params: { fontSize: 12, openLinksInNewTab: true, foo: 'bar' }, - }; - const actual = buildPipelineVisFunction.markdown(visState, schemasDef, uiState); + const params = { fontSize: 12, openLinksInNewTab: true, foo: 'bar' }; + const actual = buildPipelineVisFunction.markdown(params, schemasDef, uiState); expect(actual).toMatchSnapshot(); }); describe('handles table function', () => { it('without splits or buckets', () => { - const visState = { ...visStateDef, params: { foo: 'bar' } }; + const params = { foo: 'bar' }; const schemas = { ...schemasDef, metric: [ @@ -172,22 +151,22 @@ describe('visualize loader pipeline helpers: build pipeline', () => { { ...schemaConfig, accessor: 1 }, ], }; - const actual = buildPipelineVisFunction.table(visState, schemas, uiState); + const actual = buildPipelineVisFunction.table(params, schemas, uiState); expect(actual).toMatchSnapshot(); }); it('with splits', () => { - const visState = { ...visStateDef, params: { foo: 'bar' } }; + const params = { foo: 'bar' }; const schemas = { ...schemasDef, split_row: [1, 2], }; - const actual = buildPipelineVisFunction.table(visState, schemas, uiState); + const actual = buildPipelineVisFunction.table(params, schemas, uiState); expect(actual).toMatchSnapshot(); }); it('with splits and buckets', () => { - const visState = { ...visStateDef, params: { foo: 'bar' } }; + const params = { foo: 'bar' }; const schemas = { ...schemasDef, metric: [ @@ -197,17 +176,14 @@ describe('visualize loader pipeline helpers: build pipeline', () => { split_row: [2, 4], bucket: [3], }; - const actual = buildPipelineVisFunction.table(visState, schemas, uiState); + const actual = buildPipelineVisFunction.table(params, schemas, uiState); expect(actual).toMatchSnapshot(); }); it('with showPartialRows=true and showMetricsAtAllLevels=true', () => { - const visState = { - ...visStateDef, - params: { - showMetricsAtAllLevels: true, - showPartialRows: true, - }, + const params = { + showMetricsAtAllLevels: true, + showPartialRows: true, }; const schemas = { ...schemasDef, @@ -219,17 +195,14 @@ describe('visualize loader pipeline helpers: build pipeline', () => { ], bucket: [0, 3], }; - const actual = buildPipelineVisFunction.table(visState, schemas, uiState); + const actual = buildPipelineVisFunction.table(params, schemas, uiState); expect(actual).toMatchSnapshot(); }); it('with showPartialRows=true and showMetricsAtAllLevels=false', () => { - const visState = { - ...visStateDef, - params: { - showMetricsAtAllLevels: false, - showPartialRows: true, - }, + const params = { + showMetricsAtAllLevels: false, + showPartialRows: true, }; const schemas = { ...schemasDef, @@ -241,14 +214,14 @@ describe('visualize loader pipeline helpers: build pipeline', () => { ], bucket: [0, 3], }; - const actual = buildPipelineVisFunction.table(visState, schemas, uiState); + const actual = buildPipelineVisFunction.table(params, schemas, uiState); expect(actual).toMatchSnapshot(); }); }); describe('handles metric function', () => { it('without buckets', () => { - const visState = { ...visStateDef, params: { metric: {} } }; + const params = { metric: {} }; const schemas = { ...schemasDef, metric: [ @@ -256,12 +229,12 @@ describe('visualize loader pipeline helpers: build pipeline', () => { { ...schemaConfig, accessor: 1 }, ], }; - const actual = buildPipelineVisFunction.metric(visState, schemas, uiState); + const actual = buildPipelineVisFunction.metric(params, schemas, uiState); expect(actual).toMatchSnapshot(); }); it('with buckets', () => { - const visState = { ...visStateDef, params: { metric: {} } }; + const params = { metric: {} }; const schemas = { ...schemasDef, metric: [ @@ -270,21 +243,21 @@ describe('visualize loader pipeline helpers: build pipeline', () => { ], group: [{ accessor: 2 }], }; - const actual = buildPipelineVisFunction.metric(visState, schemas, uiState); + const actual = buildPipelineVisFunction.metric(params, schemas, uiState); expect(actual).toMatchSnapshot(); }); it('with percentage mode should have percentage format', () => { - const visState = { ...visStateDef, params: { metric: { percentageMode: true } } }; + const params = { metric: { percentageMode: true } }; const schemas = { ...schemasDef }; - const actual = buildPipelineVisFunction.metric(visState, schemas, uiState); + const actual = buildPipelineVisFunction.metric(params, schemas, uiState); expect(actual).toMatchSnapshot(); }); }); describe('handles tagcloud function', () => { it('without buckets', () => { - const actual = buildPipelineVisFunction.tagcloud(visStateDef, schemasDef, uiState); + const actual = buildPipelineVisFunction.tagcloud({}, schemasDef, uiState); expect(actual).toMatchSnapshot(); }); @@ -293,21 +266,21 @@ describe('visualize loader pipeline helpers: build pipeline', () => { ...schemasDef, segment: [{ accessor: 1 }], }; - const actual = buildPipelineVisFunction.tagcloud(visStateDef, schemas, uiState); + const actual = buildPipelineVisFunction.tagcloud({}, schemas, uiState); expect(actual).toMatchSnapshot(); }); it('with boolean param showLabel', () => { - const visState = { ...visStateDef, params: { showLabel: false } }; - const actual = buildPipelineVisFunction.tagcloud(visState, schemasDef, uiState); + const params = { showLabel: false }; + const actual = buildPipelineVisFunction.tagcloud(params, schemasDef, uiState); expect(actual).toMatchSnapshot(); }); }); describe('handles region_map function', () => { it('without buckets', () => { - const visState = { ...visStateDef, params: { metric: {} } }; - const actual = buildPipelineVisFunction.region_map(visState, schemasDef, uiState); + const params = { metric: {} }; + const actual = buildPipelineVisFunction.region_map(params, schemasDef, uiState); expect(actual).toMatchSnapshot(); }); @@ -316,19 +289,19 @@ describe('visualize loader pipeline helpers: build pipeline', () => { ...schemasDef, segment: [1, 2], }; - const actual = buildPipelineVisFunction.region_map(visStateDef, schemas, uiState); + const actual = buildPipelineVisFunction.region_map({}, schemas, uiState); expect(actual).toMatchSnapshot(); }); }); it('handles tile_map function', () => { - const visState = { ...visStateDef, params: { metric: {} } }; + const params = { metric: {} }; const schemas = { ...schemasDef, segment: [1, 2], geo_centroid: [3, 4], }; - const actual = buildPipelineVisFunction.tile_map(visState, schemas, uiState); + const actual = buildPipelineVisFunction.tile_map(params, schemas, uiState); expect(actual).toMatchSnapshot(); }); @@ -337,7 +310,7 @@ describe('visualize loader pipeline helpers: build pipeline', () => { ...schemasDef, segment: [1, 2], }; - const actual = buildPipelineVisFunction.pie(visStateDef, schemas, uiState); + const actual = buildPipelineVisFunction.pie({}, schemas, uiState); expect(actual).toMatchSnapshot(); }); }); @@ -347,11 +320,16 @@ describe('visualize loader pipeline helpers: build pipeline', () => { it('calls toExpression on vis_type if it exists', async () => { const vis = ({ - getCurrentState: () => {}, - getUiState: () => null, + getState: () => {}, isHierarchical: () => false, - aggs: { - getResponseAggs: () => [], + data: { + aggs: { + getResponseAggs: () => [], + }, + searchSource: { + getField: jest.fn(), + getParent: jest.fn(), + }, }, // @ts-ignore type: { @@ -359,7 +337,6 @@ describe('visualize loader pipeline helpers: build pipeline', () => { }, } as unknown) as Vis; const expression = await buildPipeline(vis, { - searchSource: searchSourceMock, timefilter: dataStart.query.timefilter.timefilter, }); expect(expression).toMatchSnapshot(); @@ -370,7 +347,6 @@ describe('visualize loader pipeline helpers: build pipeline', () => { const dataStart = dataPluginMock.createStartContract(); let aggs: IAggConfig[]; - let visState: any; let vis: Vis; let params: any; @@ -397,7 +373,11 @@ describe('visualize loader pipeline helpers: build pipeline', () => { describe('test y dimension format for histogram chart', () => { beforeEach(() => { - visState = { + vis = { + // @ts-ignore + type: { + name: 'histogram', + }, params: { seriesParams: [ { @@ -414,24 +394,16 @@ describe('visualize loader pipeline helpers: build pipeline', () => { }, ], }, - }; - - vis = { - // @ts-ignore - type: { - name: 'histogram', - }, - aggs: { - getResponseAggs: () => { - return aggs; - }, + data: { + aggs: { + getResponseAggs: () => { + return aggs; + }, + } as any, }, isHierarchical: () => { return false; }, - getCurrentState: () => { - return visState; - }, }; }); @@ -443,7 +415,7 @@ describe('visualize loader pipeline helpers: build pipeline', () => { }); it('with one numeric metric in percentage mode', async () => { - visState.params.valueAxes[0].scale.mode = 'percentage'; + vis.params.valueAxes[0].scale.mode = 'percentage'; const dimensions = await buildVislibDimensions(vis, params); const expected = { id: 'percent' }; const actual = dimensions.y[0].format; @@ -454,33 +426,31 @@ describe('visualize loader pipeline helpers: build pipeline', () => { const aggConfig = aggs[0]; aggs = [{ ...aggConfig } as IAggConfig, { ...aggConfig, id: '5' } as IAggConfig]; - visState = { - params: { - seriesParams: [ - { - data: { id: '0' }, - valueAxis: 'axis-y-1', - }, - { - data: { id: '5' }, - valueAxis: 'axis-y-2', - }, - ], - valueAxes: [ - { - id: 'axis-y-1', - scale: { - mode: 'normal', - }, + vis.params = { + seriesParams: [ + { + data: { id: '0' }, + valueAxis: 'axis-y-1', + }, + { + data: { id: '5' }, + valueAxis: 'axis-y-2', + }, + ], + valueAxes: [ + { + id: 'axis-y-1', + scale: { + mode: 'normal', }, - { - id: 'axis-y-2', - scale: { - mode: 'percentage', - }, + }, + { + id: 'axis-y-2', + scale: { + mode: 'percentage', }, - ], - }, + }, + ], }; const dimensions = await buildVislibDimensions(vis, params); @@ -493,29 +463,27 @@ describe('visualize loader pipeline helpers: build pipeline', () => { describe('test y dimension format for gauge chart', () => { beforeEach(() => { - visState = { params: { gauge: {} } }; - vis = { // @ts-ignore type: { name: 'gauge', }, - aggs: { - getResponseAggs: () => { - return aggs; - }, + params: { gauge: {} }, + data: { + aggs: { + getResponseAggs: () => { + return aggs; + }, + } as any, }, isHierarchical: () => { return false; }, - getCurrentState: () => { - return visState; - }, }; }); it('with percentageMode = false', async () => { - visState.params.gauge.percentageMode = false; + vis.params.gauge.percentageMode = false; const dimensions = await buildVislibDimensions(vis, params); const expected = { id: 'number' }; const actual = dimensions.y[0].format; @@ -523,7 +491,7 @@ describe('visualize loader pipeline helpers: build pipeline', () => { }); it('with percentageMode = true', async () => { - visState.params.gauge.percentageMode = true; + vis.params.gauge.percentageMode = true; const dimensions = await buildVislibDimensions(vis, params); const expected = { id: 'percent' }; const actual = dimensions.y[0].format; diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts index de974e6e969ef..ea15cd9201fd7 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts @@ -21,14 +21,13 @@ import { get } from 'lodash'; import moment from 'moment'; import { SerializedFieldFormat } from '../../../../../../../plugins/expressions/public'; import { - fieldFormats, IAggConfig, - ISearchSource, + fieldFormats, search, TimefilterContract, } from '../../../../../../../plugins/data/public'; -const { isDateHistogramBucketAggConfig } = search.aggs; import { Vis, VisParams } from '../types'; +const { isDateHistogramBucketAggConfig } = search.aggs; interface SchemaConfigParams { precision?: number; @@ -59,7 +58,7 @@ export interface Schemas { } type buildVisFunction = ( - visState: ReturnType<Vis['getCurrentState']>, + params: VisParams, schemas: Schemas, uiState: any, meta?: { savedObjectId?: string } @@ -139,7 +138,12 @@ const getSchemas = ( const schemas: Schemas = { metric: [], }; - const responseAggs = vis.aggs.getResponseAggs().filter((agg: IAggConfig) => agg.enabled); + + if (!vis.data.aggs) { + return schemas; + } + + const responseAggs = vis.data.aggs.getResponseAggs().filter((agg: IAggConfig) => agg.enabled); const isHierarchical = vis.isHierarchical(); const metrics = responseAggs.filter((agg: IAggConfig) => agg.type.type === 'metrics'); responseAggs.forEach((agg: IAggConfig) => { @@ -228,9 +232,8 @@ export const prepareDimension = (variable: string, data: any) => { }; const adjustVislibDimensionFormmaters = (vis: Vis, dimensions: { y: any[] }): void => { - const visState = vis.getCurrentState(); - const visConfig = visState.params; - const responseAggs = vis.aggs.getResponseAggs().filter((agg: IAggConfig) => agg.enabled); + const visConfig = vis.params; + const responseAggs = vis.data.aggs!.getResponseAggs().filter((agg: IAggConfig) => agg.enabled); (dimensions.y || []).forEach(yDimension => { const yAgg = responseAggs[yDimension.accessor]; @@ -252,27 +255,26 @@ const adjustVislibDimensionFormmaters = (vis: Vis, dimensions: { y: any[] }): vo }; export const buildPipelineVisFunction: BuildPipelineVisFunction = { - vega: visState => { - return `vega ${prepareString('spec', visState.params.spec)}`; + vega: params => { + return `vega ${prepareString('spec', params.spec)}`; }, - input_control_vis: visState => { - return `input_control_vis ${prepareJson('visConfig', visState.params)}`; + input_control_vis: params => { + return `input_control_vis ${prepareJson('visConfig', params)}`; }, - metrics: (visState, schemas, uiState = {}, meta) => { - const paramsJson = prepareJson('params', visState.params); + metrics: (params, schemas, uiState = {}) => { + const paramsJson = prepareJson('params', params); const uiStateJson = prepareJson('uiState', uiState); - const savedObjectIdParam = prepareString('savedObjectId', meta?.savedObjectId); - const params = [paramsJson, uiStateJson, savedObjectIdParam].filter(param => Boolean(param)); - return `tsvb ${params.join(' ')}`; + const paramsArray = [paramsJson, uiStateJson].filter(param => Boolean(param)); + return `tsvb ${paramsArray.join(' ')}`; }, - timelion: visState => { - const expression = prepareString('expression', visState.params.expression); - const interval = prepareString('interval', visState.params.interval); + timelion: params => { + const expression = prepareString('expression', params.expression); + const interval = prepareString('interval', params.interval); return `timelion_vis ${expression}${interval}`; }, - markdown: visState => { - const { markdown, fontSize, openLinksInNewTab } = visState.params; + markdown: params => { + const { markdown, fontSize, openLinksInNewTab } = params; let escapedMarkdown = ''; if (typeof markdown === 'string' || markdown instanceof String) { escapedMarkdown = escapeString(markdown.toString()); @@ -282,14 +284,14 @@ export const buildPipelineVisFunction: BuildPipelineVisFunction = { expr += prepareValue('openLinksInNewTab', openLinksInNewTab); return expr; }, - table: (visState, schemas) => { + table: (params, schemas) => { const visConfig = { - ...visState.params, - ...buildVisConfig.table(schemas, visState.params), + ...params, + ...buildVisConfig.table(schemas, params), }; return `kibana_table ${prepareJson('visConfig', visConfig)}`; }, - metric: (visState, schemas) => { + metric: (params, schemas) => { const { percentageMode, useRanges, @@ -299,11 +301,11 @@ export const buildPipelineVisFunction: BuildPipelineVisFunction = { labels, invertColors, style, - } = visState.params.metric; + } = params.metric; const { metrics, bucket } = buildVisConfig.metric(schemas).dimensions; // fix formatter for percentage mode - if (get(visState.params, 'metric.percentageMode') === true) { + if (get(params, 'metric.percentageMode') === true) { metrics.forEach((metric: SchemaConfig) => { metric.format = { id: 'percent' }; }); @@ -335,8 +337,8 @@ export const buildPipelineVisFunction: BuildPipelineVisFunction = { return expr; }, - tagcloud: (visState, schemas) => { - const { scale, orientation, minFontSize, maxFontSize, showLabel } = visState.params; + tagcloud: (params, schemas) => { + const { scale, orientation, minFontSize, maxFontSize, showLabel } = params; const { metric, bucket } = buildVisConfig.tagcloud(schemas); let expr = `tagcloud metric={visdimension ${metric.accessor}} `; expr += prepareValue('scale', scale); @@ -348,23 +350,23 @@ export const buildPipelineVisFunction: BuildPipelineVisFunction = { return expr; }, - region_map: (visState, schemas) => { + region_map: (params, schemas) => { const visConfig = { - ...visState.params, + ...params, ...buildVisConfig.region_map(schemas), }; return `regionmap ${prepareJson('visConfig', visConfig)}`; }, - tile_map: (visState, schemas) => { + tile_map: (params, schemas) => { const visConfig = { - ...visState.params, + ...params, ...buildVisConfig.tile_map(schemas), }; return `tilemap ${prepareJson('visConfig', visConfig)}`; }, - pie: (visState, schemas) => { + pie: (params, schemas) => { const visConfig = { - ...visState.params, + ...params, ...buildVisConfig.pie(schemas), }; return `kibana_pie ${prepareJson('visConfig', visConfig)}`; @@ -440,7 +442,6 @@ const buildVisConfig: BuildVisConfigFunction = { export const buildVislibDimensions = async ( vis: any, params: { - searchSource: any; timefilter: TimefilterContract; timeRange?: any; abortSignal?: AbortSignal; @@ -460,7 +461,7 @@ export const buildVislibDimensions = async ( splitColumn: schemas.split_column, }; if (schemas.segment) { - const xAgg = vis.aggs.getResponseAggs()[dimensions.x.accessor]; + const xAgg = vis.data.aggs.getResponseAggs()[dimensions.x.accessor]; if (xAgg.type.name === 'date_histogram') { dimensions.x.params.date = true; const { esUnit, esValue } = xAgg.buckets.getInterval(); @@ -472,7 +473,7 @@ export const buildVislibDimensions = async ( } else if (xAgg.type.name === 'histogram') { const intervalParam = xAgg.type.paramByName('interval'); const output = { params: {} as any }; - await intervalParam.modifyAggConfigOnSearchRequestStart(xAgg, params.searchSource, { + await intervalParam.modifyAggConfigOnSearchRequestStart(xAgg, vis.data.searchSource, { abortSignal: params.abortSignal, }); intervalParam.write(xAgg, output); @@ -487,18 +488,14 @@ export const buildVislibDimensions = async ( export const buildPipeline = async ( vis: Vis, params: { - searchSource: ISearchSource; timefilter: TimefilterContract; timeRange?: any; - savedObjectId?: string; } ) => { - const { searchSource } = params; - const { indexPattern } = vis; - const query = searchSource.getField('query'); - const filters = searchSource.getField('filter'); - const visState = vis.getCurrentState(); - const uiState = vis.getUiState(); + const { indexPattern, searchSource } = vis.data; + const query = searchSource!.getField('query'); + const filters = searchSource!.getField('filter'); + const { uiState } = vis; // context let pipeline = `kibana | kibana_context `; @@ -508,18 +505,18 @@ export const buildPipeline = async ( if (filters) { pipeline += prepareJson('filters', filters); } - if (vis.savedSearchId) { - pipeline += prepareString('savedSearchId', vis.savedSearchId); + if (vis.data.savedSearchId) { + pipeline += prepareString('savedSearchId', vis.data.savedSearchId); } pipeline += '| '; // request handler if (vis.type.requestHandler === 'courier') { pipeline += `esaggs - ${prepareString('index', indexPattern.id)} + ${prepareString('index', indexPattern!.id)} metricsAtAllLevels=${vis.isHierarchical()} partialRows=${vis.type.requiresPartialRows || vis.params.showPartialRows || false} - ${prepareJson('aggConfigs', visState.aggs)} | `; + ${prepareJson('aggConfigs', vis.data.aggs!.aggs)} | `; } const schemas = getSchemas(vis, { @@ -527,18 +524,16 @@ export const buildPipeline = async ( timefilter: params.timefilter, }); if (buildPipelineVisFunction[vis.type.name]) { - pipeline += buildPipelineVisFunction[vis.type.name](visState, schemas, uiState, { - savedObjectId: params.savedObjectId, - }); + pipeline += buildPipelineVisFunction[vis.type.name](vis.params, schemas, uiState); } else if (vislibCharts.includes(vis.type.name)) { - const visConfig = visState.params; + const visConfig = { ...vis.params }; visConfig.dimensions = await buildVislibDimensions(vis, params); - pipeline += `vislib type='${vis.type.name}' ${prepareJson('visConfig', visState.params)}`; + pipeline += `vislib type='${vis.type.name}' ${prepareJson('visConfig', visConfig)}`; } else if (vis.type.toExpression) { pipeline += await vis.type.toExpression(vis, params); } else { - const visConfig = visState.params; + const visConfig = { ...vis.params }; visConfig.dimensions = schemas; pipeline += `visualization type='${vis.type.name}' ${prepareJson('visConfig', visConfig)} diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/update_status.test.js b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/update_status.test.js deleted file mode 100644 index c63a8cd48e625..0000000000000 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/update_status.test.js +++ /dev/null @@ -1,102 +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 { getUpdateStatus, Status } from './update_status'; - -// Parts of the tests in this file are generated more dynamically, based on the -// values inside the Status object.Make sure this object has one function per entry -// in Status, that actually change on the passed $scope, what needs to be changed -// so that we expect the getUpdateStatus function to actually detect a change. -const changeFunctions = { - [Status.AGGS]: $scope => ($scope.vis.aggs = { foo: 'new' }), - [Status.DATA]: $scope => ($scope.visData = { foo: 'new' }), - [Status.PARAMS]: $scope => ($scope.vis.params = { foo: 'new' }), - [Status.RESIZE]: $scope => ($scope.vis.size = [50, 50]), - [Status.TIME]: $scope => ($scope.vis.filters.timeRange = { from: 'now-7d', to: 'now' }), - [Status.UI_STATE]: $scope => ($scope.uiState = { foo: 'new' }), -}; - -describe('getUpdateStatus', () => { - function getScope() { - return { - vis: { - aggs: {}, - size: [100, 100], - params: {}, - filters: {}, - }, - uiState: {}, - visData: {}, - }; - } - - function initStatusCheckerAndChangeProperty(type, requiresUpdateStatus) { - const $scope = getScope(); - // Call the getUpdateStatus function initially, so it can store it's current state - getUpdateStatus(requiresUpdateStatus, $scope, $scope); - - // Get the change function for that specific change type - const changeFn = changeFunctions[type]; - if (!changeFn) { - throw new Error(`Please implement the test change function for ${type}.`); - } - - // Call that change function to manipulate the scope so it changed. - changeFn($scope); - - return getUpdateStatus(requiresUpdateStatus, $scope, $scope); - } - - it('should be a function', () => { - expect(typeof getUpdateStatus).toBe('function'); - }); - - Object.entries(Status).forEach(([typeKey, typeValue]) => { - // This block automatically creates very simple tests for each of the Status - // keys, so we have simple tests per changed property. - // If it makes sense to test more specific behavior of a specific change detection - // please add additional tests for that. - - it(`should detect changes for Status.${typeKey}`, () => { - // Check whether the required change type is not correctly determined - const status = initStatusCheckerAndChangeProperty(typeValue, [typeValue]); - expect(status[typeValue]).toBe(true); - }); - - it(`should not detect changes in other properties when changing Status.${typeKey}`, () => { - // Only change typeKey, but track changes for all status changes - const status = initStatusCheckerAndChangeProperty(typeValue, Object.values(Status)); - Object.values(Status) - // Filter out the actual changed property so we only test for all other properties - .filter(stat => stat !== typeValue) - .forEach(otherProp => { - expect(status[otherProp]).toBeFalsy(); - }); - }); - - it(`should not detect changes if not requested for Status.${typeKey}`, () => { - const allOtherStatusProperties = Object.values(Status).filter(stat => stat !== typeValue); - // Change only the typeKey property, but do not listen for changes on it - // listen on all other status changes instead. - const status = initStatusCheckerAndChangeProperty(typeValue, allOtherStatusProperties); - // The typeValue check should be falsy, since we did not request tracking it. - expect(status[typeValue]).toBeFalsy(); - }); - }); -}); diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/update_status.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/update_status.ts deleted file mode 100644 index 92a9ce8366f4f..0000000000000 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/update_status.ts +++ /dev/null @@ -1,117 +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 { PersistedState } from '../../../../../../../plugins/visualizations/public'; -import { calculateObjectHash } from '../../../../../../../plugins/kibana_utils/common'; -import { Vis } from '../vis'; - -enum Status { - AGGS = 'aggs', - DATA = 'data', - PARAMS = 'params', - RESIZE = 'resize', - TIME = 'time', - UI_STATE = 'uiState', -} - -/** - * Checks whether the hash of a specific key in the given oldStatus has changed - * compared to the new valueHash passed. - */ -function hasHashChanged<T extends string>( - valueHash: string, - oldStatus: { [key in T]?: string }, - name: T -): boolean { - const oldHash = oldStatus[name]; - return oldHash !== valueHash; -} - -interface Size { - width: number; - height: number; -} - -function hasSizeChanged(size: Size, oldSize?: Size): boolean { - if (!oldSize) { - return true; - } - return oldSize.width !== size.width || oldSize.height !== size.height; -} - -function getUpdateStatus<T extends Status>( - requiresUpdateStatus: T[] = [], - obj: any, - param: { vis: Vis; visData: any; uiState: PersistedState } -): { [reqStats in T]: boolean } { - const status = {} as { [reqStats in Status]: boolean }; - - // If the vis type doesn't need update status, skip all calculations - if (requiresUpdateStatus.length === 0) { - return status; - } - - if (!obj._oldStatus) { - obj._oldStatus = {}; - } - - for (const requiredStatus of requiresUpdateStatus) { - let hash; - // Calculate all required status updates for this visualization - switch (requiredStatus) { - case Status.AGGS: - hash = calculateObjectHash(param.vis.aggs); - status.aggs = hasHashChanged(hash, obj._oldStatus, 'aggs'); - obj._oldStatus.aggs = hash; - break; - case Status.DATA: - hash = calculateObjectHash(param.visData); - status.data = hasHashChanged(hash, obj._oldStatus, 'data'); - obj._oldStatus.data = hash; - break; - case Status.PARAMS: - hash = calculateObjectHash(param.vis.params); - status.params = hasHashChanged(hash, obj._oldStatus, 'param'); - obj._oldStatus.param = hash; - break; - case Status.RESIZE: - const width: number = param.vis.size ? param.vis.size[0] : 0; - const height: number = param.vis.size ? param.vis.size[1] : 0; - const size = { width, height }; - status.resize = hasSizeChanged(size, obj._oldStatus.resize); - obj._oldStatus.resize = size; - break; - case Status.TIME: - const timeRange = param.vis.filters && param.vis.filters.timeRange; - hash = calculateObjectHash(timeRange); - status.time = hasHashChanged(hash, obj._oldStatus, 'time'); - obj._oldStatus.time = hash; - break; - case Status.UI_STATE: - hash = calculateObjectHash(param.uiState); - status.uiState = hasHashChanged(hash, obj._oldStatus, 'uiState'); - obj._oldStatus.uiState = hash; - break; - } - } - - return status; -} - -export { getUpdateStatus, Status }; diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts index 4ee727e46f4d6..17f777e4e80e1 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts @@ -41,6 +41,8 @@ const createStartContract = (): VisualizationsStart => ({ savedVisualizationsLoader: {} as any, showNewVisModal: jest.fn(), createVis: jest.fn(), + convertFromSerializedVis: jest.fn(), + convertToSerializedVis: jest.fn(), }); const createInstance = async () => { diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts index 953caecefb974..3ade6cee0d4d2 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts @@ -39,6 +39,8 @@ import { setSavedVisualizationsLoader, setTimeFilter, setAggs, + setChrome, + setOverlays, } from './services'; import { VISUALIZE_EMBEDDABLE_TYPE, VisualizeEmbeddableFactory } from './embeddable'; import { ExpressionsSetup, ExpressionsStart } from '../../../../../../plugins/expressions/public'; @@ -48,14 +50,16 @@ import { visualization as visualizationRenderer } from './expressions/visualizat import { DataPublicPluginSetup, DataPublicPluginStart, - IIndexPattern, } from '../../../../../../plugins/data/public'; import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/public'; import { createSavedVisLoader, SavedVisualizationsLoader } from './saved_visualizations'; -import { VisImpl } from './vis_impl'; +import { SerializedVis, Vis } from './vis'; import { showNewVisModal } from './wizard'; import { UiActionsStart } from '../../../../../../plugins/ui_actions/public'; -import { VisState } from './types'; +import { + convertFromSerializedVis, + convertToSerializedVis, +} from './saved_visualizations/_saved_vis'; /** * Interface for this plugin's returned setup/start contracts. @@ -67,7 +71,9 @@ export type VisualizationsSetup = TypesSetup; export interface VisualizationsStart extends TypesStart { savedVisualizationsLoader: SavedVisualizationsLoader; - createVis: (indexPattern: IIndexPattern, visState?: VisState) => VisImpl; + createVis: (visType: string, visState?: SerializedVis) => Vis; + convertToSerializedVis: typeof convertToSerializedVis; + convertFromSerializedVis: typeof convertFromSerializedVis; showNewVisModal: typeof showNewVisModal; } @@ -138,6 +144,8 @@ export class VisualizationsPlugin setUiActions(uiActions); setTimeFilter(data.query.timefilter.timefilter); setAggs(data.search.aggs); + setOverlays(core.overlays); + setChrome(core.chrome); const savedVisualizationsLoader = createSavedVisLoader({ savedObjectsClient: core.savedObjects.client, indexPatterns: data.indexPatterns, @@ -155,8 +163,9 @@ export class VisualizationsPlugin * @param {IIndexPattern} indexPattern - index pattern to use * @param {VisState} visState - visualization configuration */ - createVis: (indexPattern: IIndexPattern, visState?: VisState) => - new VisImpl(indexPattern, visState), + createVis: (visType: string, visState?: SerializedVis) => new Vis(visType, visState), + convertToSerializedVis, + convertFromSerializedVis, savedVisualizationsLoader, }; } diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/saved_visualizations/_saved_vis.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/saved_visualizations/_saved_vis.ts index e381a01edef8b..c9906428ccb31 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/saved_visualizations/_saved_vis.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/saved_visualizations/_saved_vis.ts @@ -32,65 +32,74 @@ import { // @ts-ignore import { updateOldState } from '../legacy/vis_update_state'; import { extractReferences, injectReferences } from './saved_visualization_references'; -import { IIndexPattern } from '../../../../../../../plugins/data/public'; -import { VisSavedObject } from '../types'; -import { VisImpl } from '../vis_impl'; +import { + IIndexPattern, + ISearchSource, + SearchSource, +} from '../../../../../../../plugins/data/public'; +import { ISavedVis, SerializedVis } from '../types'; import { createSavedSearchesLoader } from '../../../../../../../plugins/discover/public'; - -async function _afterEsResp(savedVis: VisSavedObject, services: any) { - await _getLinkedSavedSearch(savedVis, services); - savedVis.searchSource!.setField('size', 0); - savedVis.vis = savedVis.vis ? _updateVis(savedVis) : await _createVis(savedVis); - return savedVis; -} - -async function _getLinkedSavedSearch(savedVis: VisSavedObject, services: any) { - const linkedSearch = !!savedVis.savedSearchId; - const current = savedVis.savedSearch; - - if (linkedSearch && current && current.id === savedVis.savedSearchId) { - return; - } - - if (savedVis.savedSearch) { - savedVis.searchSource!.setParent(savedVis.savedSearch.searchSource.getParent()); - savedVis.savedSearch.destroy(); - delete savedVis.savedSearch; - } - const savedSearches = createSavedSearchesLoader(services); - - if (linkedSearch) { - savedVis.savedSearch = await savedSearches.get(savedVis.savedSearchId!); - savedVis.searchSource!.setParent(savedVis.savedSearch!.searchSource); - } -} - -async function _createVis(savedVis: VisSavedObject) { - savedVis.visState = updateOldState(savedVis.visState); - - // visState doesn't yet exist when importing a visualization, so we can't - // assume that exists at this point. If it does exist, then we're not - // importing a visualization, so we want to sync the title. - if (savedVis.visState) { - savedVis.visState.title = savedVis.title; - } - - savedVis.vis = new VisImpl(savedVis.searchSource!.getField('index')!, savedVis.visState); - - savedVis.vis!.savedSearchId = savedVis.savedSearchId; - - return savedVis.vis; -} - -function _updateVis(savedVis: VisSavedObject) { - if (savedVis.vis && savedVis.searchSource) { - savedVis.vis.indexPattern = savedVis.searchSource.getField('index'); - savedVis.visState.title = savedVis.title; - savedVis.vis.setState(savedVis.visState); - savedVis.vis.savedSearchId = savedVis.savedSearchId; +import { getChrome, getOverlays, getIndexPatterns, getSavedObjects } from '../services'; + +export const convertToSerializedVis = async (savedVis: ISavedVis): Promise<SerializedVis> => { + const { visState } = savedVis; + const searchSource = + savedVis.searchSource && (await getSearchSource(savedVis.searchSource, savedVis.savedSearchId)); + + const indexPattern = + searchSource && searchSource.getField('index') ? searchSource.getField('index')!.id : undefined; + + const aggs = indexPattern ? visState.aggs || [] : visState.aggs; + + return { + id: savedVis.id, + title: savedVis.title, + type: visState.type, + description: savedVis.description, + params: visState.params, + uiState: JSON.parse(savedVis.uiStateJSON || '{}'), + data: { + indexPattern, + aggs, + searchSource, + savedSearchId: savedVis.savedSearchId, + }, + }; +}; + +export const convertFromSerializedVis = (vis: SerializedVis): ISavedVis => { + return { + id: vis.id, + title: vis.title, + description: vis.description, + visState: { + type: vis.type, + aggs: vis.data.aggs, + params: vis.params, + }, + uiStateJSON: JSON.stringify(vis.uiState), + searchSource: vis.data.searchSource!, + savedSearchId: vis.data.savedSearchId, + }; +}; + +const getSearchSource = async (inputSearchSource: ISearchSource, savedSearchId?: string) => { + const searchSource = inputSearchSource.createCopy + ? inputSearchSource.createCopy() + : new SearchSource({ ...(inputSearchSource as any).fields }); + if (savedSearchId) { + const savedSearch = await createSavedSearchesLoader({ + savedObjectsClient: getSavedObjects().client, + indexPatterns: getIndexPatterns(), + chrome: getChrome(), + overlays: getOverlays(), + }).get(savedSearchId); + + searchSource.setParent(savedSearch.searchSource); } - return savedVis.vis; -} + searchSource!.setField('size', 0); + return searchSource; +}; export function createSavedVisClass(services: SavedObjectKibanaServices) { const SavedObjectClass = createSavedObjectClass(services); @@ -131,8 +140,16 @@ export function createSavedVisClass(services: SavedObjectKibanaServices) { savedSearchId: opts.savedSearchId, version: 1, }, - afterESResp: (savedObject: SavedObject) => { - return _afterEsResp(savedObject as VisSavedObject, services) as Promise<SavedObject>; + afterESResp: async (savedObject: SavedObject) => { + const savedVis = (savedObject as any) as ISavedVis; + savedVis.visState = await updateOldState(savedVis.visState); + if (savedVis.savedSearchId && savedVis.searchSource) { + savedObject.searchSource = await getSearchSource( + savedVis.searchSource, + savedVis.savedSearchId + ); + } + return (savedVis as any) as SavedObject; }, }); this.showInRecentlyAccessed = true; diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/saved_visualizations/saved_visualization_references.test.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/saved_visualizations/saved_visualization_references.test.ts index 98af6d99025c2..2e3a4f0f58b27 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/saved_visualizations/saved_visualization_references.test.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/saved_visualizations/saved_visualization_references.test.ts @@ -18,7 +18,7 @@ */ import { extractReferences, injectReferences } from './saved_visualization_references'; -import { VisSavedObject, VisState } from '../types'; +import { VisSavedObject, SavedVisState } from '../types'; describe('extractReferences', () => { test('extracts nothing if savedSearchId is empty', () => { @@ -140,7 +140,7 @@ Object { }, ], }, - } as unknown) as VisState, + } as unknown) as SavedVisState, } as VisSavedObject; const references = [ { @@ -201,7 +201,7 @@ Object { }, ], }, - } as unknown) as VisState, + } as unknown) as SavedVisState, } as VisSavedObject; expect(() => injectReferences(context, [])).toThrowErrorMatchingInlineSnapshot( `"Could not find index pattern reference \\"control_0_index_pattern\\""` diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/services.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/services.ts index b2eebe8b5b57d..23cdeae7d15ff 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/services.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/services.ts @@ -18,10 +18,13 @@ */ import { + ApplicationStart, Capabilities, + ChromeStart, HttpStart, I18nStart, IUiSettingsClient, + OverlayStart, SavedObjectsStart, } from '../../../../../../core/public'; import { TypesStart } from './vis_types'; @@ -76,3 +79,9 @@ export const [getSavedVisualizationsLoader, setSavedVisualizationsLoader] = crea export const [getAggs, setAggs] = createGetterSetter<DataPublicPluginStart['search']['aggs']>( 'AggConfigs' ); + +export const [getOverlays, setOverlays] = createGetterSetter<OverlayStart>('Overlays'); + +export const [getChrome, setChrome] = createGetterSetter<ChromeStart>('Chrome'); + +export const [getApplication, setApplication] = createGetterSetter<ApplicationStart>('Application'); diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/types.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/types.ts index d8e3ccdeb065e..8f93a179af3bc 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/types.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/types.ts @@ -18,21 +18,33 @@ */ import { SavedObject } from '../../../../../../plugins/saved_objects/public'; -import { Vis, VisState, VisParams, VisualizationController } from './vis'; -import { ISearchSource } from '../../../../../../plugins/data/public/'; -import { SavedSearch } from '../../../../../../plugins/discover/public'; +import { ISearchSource, AggConfigOptions } from '../../../../../../plugins/data/public'; +import { SerializedVis, Vis, VisParams } from './vis'; -export { Vis, VisState, VisParams, VisualizationController }; +export { Vis, SerializedVis, VisParams }; -export interface VisSavedObject extends SavedObject { - vis: Vis; - description?: string; - searchSource: ISearchSource; +export interface VisualizationController { + render(visData: any, visParams: any): Promise<void>; + destroy(): void; + isLoaded?(): Promise<void> | void; +} + +export interface SavedVisState { + type: string; + params: VisParams; + aggs: AggConfigOptions[]; +} + +export interface ISavedVis { + id: string; title: string; + description?: string; + visState: SavedVisState; + searchSource?: ISearchSource; uiStateJSON?: string; - destroy: () => void; savedSearchRefName?: string; savedSearchId?: string; - savedSearch?: SavedSearch; - visState: VisState; } + +// @ts-ignore-next-line +export interface VisSavedObject extends SavedObject, ISavedVis {} diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/vis.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/vis.ts index eb262966a4a22..0ba936c9f6567 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/vis.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/vis.ts @@ -17,47 +17,182 @@ * under the License. */ +/** + * @name Vis + * + * @description This class consists of aggs, params, listeners, title, and type. + * - Aggs: Instances of IAggConfig. + * - Params: The settings in the Options tab. + * + * Not to be confused with vislib/vis.js. + */ + +import { isFunction, defaults, cloneDeep } from 'lodash'; +import { PersistedState } from '../../../../../../../src/plugins/visualizations/public'; +// @ts-ignore +import { updateVisualizationConfig } from './legacy/vis_update'; +import { getTypes, getAggs } from './services'; import { VisType } from './vis_types'; -import { Status } from './legacy/update_status'; -import { IAggConfigs } from '../../../../../../plugins/data/public'; - -export interface Vis { - type: VisType; - getCurrentState: ( - includeDisabled?: boolean - ) => { - title: string; - type: string; - params: VisParams; - aggs: Array<{ [key: string]: any }>; - }; - - /** - * If a visualization based on the saved search, - * the id is necessary for building an expression function in src/plugins/expressions/common/expression_functions/specs/kibana_context.ts - */ +import { + IAggConfigs, + IndexPattern, + ISearchSource, + AggConfigOptions, +} from '../../../../../../plugins/data/public'; + +export interface SerializedVisData { + expression?: string; + aggs: AggConfigOptions[]; + indexPattern?: string; + searchSource?: ISearchSource; savedSearchId?: string; +} - // Since we haven't typed everything here yet, we basically "any" the rest - // of that interface. This should be removed as soon as this type definition - // has been completed. But that way we at least have typing for a couple of - // properties on that type. - [key: string]: any; +export interface SerializedVis { + id: string; + title: string; + description?: string; + type: string; + params: VisParams; + uiState?: any; + data: SerializedVisData; +} + +export interface VisData { + ast?: string; + aggs?: IAggConfigs; + indexPattern?: IndexPattern; + searchSource?: ISearchSource; + savedSearchId?: string; } export interface VisParams { [key: string]: any; } -export interface VisState { - title: string; - type: VisType; - params: VisParams; - aggs: IAggConfigs; -} +export class Vis { + public readonly type: VisType; + public readonly id: string; + public title: string = ''; + public description: string = ''; + public params: VisParams = {}; + // Session state is for storing information that is transitory, and will not be saved with the visualization. + // For instance, map bounds, which depends on the view port, browser window size, etc. + public sessionState: Record<string, any> = {}; + public data: VisData = {}; + + public readonly uiState: PersistedState; + + constructor(visType: string, visState: SerializedVis = {} as any) { + this.type = this.getType(visType); + this.params = this.getParams(visState.params); + this.uiState = new PersistedState(visState.uiState); + this.id = visState.id; + + this.setState(visState || {}); + } + + private getType(visType: string) { + const type = getTypes().get(visType); + if (!type) { + throw new Error(`Invalid type "${visType}"`); + } + return type; + } + + private getParams(params: VisParams) { + return defaults({}, cloneDeep(params || {}), cloneDeep(this.type.visConfig.defaults || {})); + } + + setState(state: SerializedVis) { + let typeChanged = false; + if (state.type && this.type.name !== state.type) { + // @ts-ignore + this.type = this.getType(state.type); + typeChanged = true; + } + if (state.title !== undefined) { + this.title = state.title; + } + if (state.description !== undefined) { + this.description = state.description; + } + if (state.params || typeChanged) { + this.params = this.getParams(state.params); + } + + // move to migration script + updateVisualizationConfig(state.params, this.params); + + if (state.data && state.data.searchSource) { + this.data.searchSource = state.data.searchSource!; + this.data.indexPattern = this.data.searchSource.getField('index'); + } + if (state.data && state.data.savedSearchId) { + this.data.savedSearchId = state.data.savedSearchId; + } + if (state.data && state.data.aggs) { + let configStates = state.data.aggs; + configStates = this.initializeDefaultsFromSchemas(configStates, this.type.schemas.all || []); + if (!this.data.indexPattern) { + if (state.data.aggs.length) { + throw new Error('trying to initialize aggs without index pattern'); + } + return; + } + this.data.aggs = getAggs().createAggConfigs(this.data.indexPattern, configStates); + } + } + + clone() { + return new Vis(this.type.name, this.serialize()); + } + + serialize(): SerializedVis { + const aggs = this.data.aggs ? this.data.aggs.aggs.map(agg => agg.toJSON()) : []; + const indexPattern = this.data.searchSource && this.data.searchSource.getField('index'); + return { + id: this.id, + title: this.title, + type: this.type.name, + params: cloneDeep(this.params) as any, + uiState: this.uiState.toJSON(), + data: { + aggs: aggs as any, + indexPattern: indexPattern ? indexPattern.id : undefined, + searchSource: this.data.searchSource!.createCopy(), + savedSearchId: this.data.savedSearchId, + }, + }; + } + + toAST() { + return this.type.toAST(this.params); + } + + // deprecated + isHierarchical() { + if (isFunction(this.type.hierarchicalData)) { + return !!this.type.hierarchicalData(this); + } else { + return !!this.type.hierarchicalData; + } + } -export interface VisualizationController { - render(visData: any, visParams: any, update: { [key in Status]: boolean }): Promise<void>; - destroy(): void; - isLoaded?(): Promise<void> | void; + private initializeDefaultsFromSchemas(configStates: AggConfigOptions[], schemas: any) { + // Set the defaults for any schema which has them. If the defaults + // for some reason has more then the max only set the max number + // of defaults (not sure why a someone define more... + // but whatever). Also if a schema.name is already set then don't + // set anything. + const newConfigs = [...configStates]; + schemas + .filter((schema: any) => Array.isArray(schema.defaults) && schema.defaults.length > 0) + .filter((schema: any) => !configStates.find(agg => agg.schema && agg.schema === schema.name)) + .forEach((schema: any) => { + const defaultSchemaConfig = schema.defaults.slice(0, schema.max); + defaultSchemaConfig.forEach((d: any) => newConfigs.push(d)); + }); + return newConfigs; + } } diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.d.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.d.ts deleted file mode 100644 index 0e759c3d9872c..0000000000000 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.d.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 { Vis, VisState, VisParams } from './vis'; -import { VisType } from './vis_types'; -import { IAggConfig, IIndexPattern } from '../../../../../../plugins/data/public'; -import { Schema } from '../../../../vis_default_editor/public'; - -type InitVisStateType = - | Partial<VisState> - | Partial<Omit<VisState, 'type'> & { type: string }> - | string; - -export type VisImplConstructor = new ( - indexPattern: IIndexPattern, - visState?: InitVisStateType -) => VisImpl; - -export declare class VisImpl implements Vis { - constructor(indexPattern: IIndexPattern, visState?: InitVisStateType); - - type: VisType; - getCurrentState: ( - includeDisabled?: boolean - ) => { - title: string; - type: string; - params: VisParams; - aggs: Array<{ [key: string]: any }>; - }; - - private initializeDefaultsFromSchemas(configStates: IAggConfig[], schemas: Schema[]); - - // Since we haven't typed everything here yet, we basically "any" the rest - // of that interface. This should be removed as soon as this type definition - // has been completed. But that way we at least have typing for a couple of - // properties on that type. - [key: string]: any; -} diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.js b/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.js deleted file mode 100644 index abd8f351ae94d..0000000000000 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/vis_impl.js +++ /dev/null @@ -1,223 +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. - */ - -/** - * @name Vis - * - * @description This class consists of aggs, params, listeners, title, and type. - * - Aggs: Instances of IAggConfig. - * - Params: The settings in the Options tab. - * - * Not to be confused with vislib/vis.js. - */ - -import { EventEmitter } from 'events'; -import _ from 'lodash'; -import { PersistedState } from '../../../../../../../src/plugins/visualizations/public'; -import { updateVisualizationConfig } from './legacy/vis_update'; -import { getTypes, getAggs } from './services'; - -class VisImpl extends EventEmitter { - constructor(indexPattern, visState) { - super(); - visState = visState || {}; - - if (_.isString(visState)) { - visState = { - type: visState, - }; - } - - this.indexPattern = indexPattern; - this._setUiState(new PersistedState()); - this.setCurrentState(visState); - this.setState(this.getCurrentState(), false); - - // Session state is for storing information that is transitory, and will not be saved with the visualization. - // For instance, map bounds, which depends on the view port, browser window size, etc. - this.sessionState = {}; - - this.API = { - events: { - filter: data => this.eventsSubject.next({ name: 'filterBucket', data }), - brush: data => this.eventsSubject.next({ name: 'brush', data }), - }, - }; - } - - initializeDefaultsFromSchemas(configStates, schemas) { - // Set the defaults for any schema which has them. If the defaults - // for some reason has more then the max only set the max number - // of defaults (not sure why a someone define more... - // but whatever). Also if a schema.name is already set then don't - // set anything. - const newConfigs = [...configStates]; - schemas - .filter(schema => Array.isArray(schema.defaults) && schema.defaults.length > 0) - .filter(schema => !configStates.find(agg => agg.schema && agg.schema === schema.name)) - .forEach(schema => { - const defaults = schema.defaults.slice(0, schema.max); - defaults.forEach(d => newConfigs.push(d)); - }); - return newConfigs; - } - - setCurrentState(state) { - this.title = state.title || ''; - const type = state.type || this.type; - if (_.isString(type)) { - this.type = getTypes().get(type); - if (!this.type) { - throw new Error(`Invalid type "${type}"`); - } - } else { - this.type = type; - } - - this.params = _.defaults( - {}, - _.cloneDeep(state.params || {}), - _.cloneDeep(this.type.visConfig.defaults || {}) - ); - - updateVisualizationConfig(state.params, this.params); - - if (state.aggs || !this.aggs) { - let configStates = state.aggs ? state.aggs.aggs || state.aggs : []; - configStates = this.initializeDefaultsFromSchemas(configStates, this.type.schemas.all || []); - this.aggs = getAggs().createAggConfigs(this.indexPattern, configStates); - } - } - - setState(state, updateCurrentState = true) { - this._state = _.cloneDeep(state); - if (updateCurrentState) { - this.setCurrentState(this._state); - } - } - - setVisType(type) { - this.type.type = type; - } - - updateState() { - this.setState(this.getCurrentState(true)); - this.emit('update'); - } - - forceReload() { - this.emit('reload'); - } - - getCurrentState(includeDisabled) { - return { - title: this.title, - type: this.type.name, - params: _.cloneDeep(this.params), - aggs: this.aggs.aggs - .map(agg => agg.toJSON()) - .filter(agg => includeDisabled || agg.enabled) - .filter(Boolean), - }; - } - - copyCurrentState(includeDisabled = false) { - const state = this.getCurrentState(includeDisabled); - state.aggs = getAggs().createAggConfigs( - this.indexPattern, - state.aggs.aggs || state.aggs, - this.type.schemas.all - ); - return state; - } - - getStateInternal(includeDisabled) { - return { - title: this._state.title, - type: this._state.type, - params: this._state.params, - aggs: this._state.aggs.filter(agg => includeDisabled || agg.enabled), - }; - } - - getEnabledState() { - return this.getStateInternal(false); - } - - getAggConfig() { - return this.aggs.clone({ enabledOnly: true }); - } - - getState() { - return this.getStateInternal(true); - } - - isHierarchical() { - if (_.isFunction(this.type.hierarchicalData)) { - return !!this.type.hierarchicalData(this); - } else { - return !!this.type.hierarchicalData; - } - } - - hasSchemaAgg(schemaName, aggTypeName) { - const aggs = this.aggs.bySchemaName(schemaName) || []; - return aggs.some(function(agg) { - if (!agg.type || !agg.type.name) return false; - return agg.type.name === aggTypeName; - }); - } - - hasUiState() { - return !!this.__uiState; - } - - /*** - * this should not be used outside of visualize - * @param uiState - * @private - */ - _setUiState(uiState) { - if (uiState instanceof PersistedState) { - this.__uiState = uiState; - } - } - - getUiState() { - return this.__uiState; - } - - /** - * Currently this is only used to extract map-specific information - * (e.g. mapZoom, mapCenter). - */ - uiStateVal(key, val) { - if (this.hasUiState()) { - if (_.isUndefined(val)) { - return this.__uiState.get(key); - } - return this.__uiState.set(key, val); - } - return val; - } -} - -VisImpl.prototype.type = 'histogram'; - -export { VisImpl }; diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 339a5fea91c5f..977b9568ceaa6 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -328,6 +328,7 @@ export { AggParamType, AggTypeFieldFilters, // TODO convert to interface AggTypeFilters, // TODO convert to interface + AggConfigOptions, BUCKET_TYPES, DateRangeKey, // only used in field formatter deserialization, which will live in data IAggConfig, diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index fac16973f92a3..f0807187fc254 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -54,6 +54,22 @@ import { Unit } from '@elastic/datemath'; import { UnregisterCallback } from 'history'; import { UserProvidedValues } from 'src/core/server/types'; +// Warning: (ae-missing-release-tag) "AggConfigOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface AggConfigOptions { + // (undocumented) + enabled?: boolean; + // (undocumented) + id?: string; + // (undocumented) + params?: Record<string, any>; + // (undocumented) + schema?: string; + // (undocumented) + type: IAggType; +} + // Warning: (ae-missing-release-tag) "AggGroupNames" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1832,21 +1848,21 @@ export type TSearchStrategyProvider<T extends TStrategyTypes> = (context: ISearc // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "getRoutes" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:379:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:379:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:379:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:379:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:384:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:385:1 - (ae-forgotten-export) The symbol "convertDateRangeToString" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:387:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:398:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:402:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:405:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:406:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:385:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:386:1 - (ae-forgotten-export) The symbol "convertDateRangeToString" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:397:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:398:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:402:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:403:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:406:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:407:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:33:33 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:37:1 - (ae-forgotten-export) The symbol "QueryStateChange" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:52:5 - (ae-forgotten-export) The symbol "createFiltersFromEvent" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/visualizations/public/persisted_state/persisted_state.ts b/src/plugins/visualizations/public/persisted_state/persisted_state.ts index b81b651c73509..3e675574fd678 100644 --- a/src/plugins/visualizations/public/persisted_state/persisted_state.ts +++ b/src/plugins/visualizations/public/persisted_state/persisted_state.ts @@ -85,7 +85,7 @@ export class PersistedState extends EventEmitter { setSilent(key: PersistedStateKey | any, value?: any) { const params = prepSetParams(key, value, this._path); - if (params.key) { + if (params.key || params.value) { return this.setValue(params.key, params.value, true); } } From d8d06e7343d4457d6afa0e6e4b49607abe11f640 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris <github@gidi.io> Date: Mon, 23 Mar 2020 18:23:26 +0000 Subject: [PATCH 029/179] [Alerting] Fixes flaky test in Alert Instances Details page (#60893) Fixes flaky test in Alert Instances Details page --- .../apps/triggers_actions_ui/details.ts | 64 ++++++++++++------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index 145b536a26f61..2c29954528bd5 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -254,12 +254,15 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('renders the active alert instances', async () => { + // refresh to ensure Api call and UI are looking at freshest output + await browser.refresh(); + // Verify content await testSubjects.existOrFail('alertInstancesList'); const { alertInstances } = await alerting.alerts.getAlertState(alert.id); - const dateOnAllInstances = mapValues( + const dateOnAllInstancesFromApiResponse = mapValues<Record<string, number>>( alertInstances, ({ meta: { @@ -268,28 +271,32 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }) => date ); - log.debug(`API RESULT: ${JSON.stringify(dateOnAllInstances)}`); + log.debug( + `API RESULT: ${Object.entries(dateOnAllInstancesFromApiResponse) + .map(([id, date]) => `${id}: ${moment(date).utc()}`) + .join(', ')}` + ); const instancesList = await pageObjects.alertDetailsUI.getAlertInstancesList(); expect(instancesList.map(instance => omit(instance, 'duration'))).to.eql([ { instance: 'us-central', status: 'Active', - start: moment(dateOnAllInstances['us-central']) + start: moment(dateOnAllInstancesFromApiResponse['us-central']) .utc() .format('D MMM YYYY @ HH:mm:ss'), }, { instance: 'us-east', status: 'Active', - start: moment(dateOnAllInstances['us-east']) + start: moment(dateOnAllInstancesFromApiResponse['us-east']) .utc() .format('D MMM YYYY @ HH:mm:ss'), }, { instance: 'us-west', status: 'Active', - start: moment(dateOnAllInstances['us-west']) + start: moment(dateOnAllInstancesFromApiResponse['us-west']) .utc() .format('D MMM YYYY @ HH:mm:ss'), }, @@ -299,30 +306,41 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.alertDetailsUI.getAlertInstanceDurationEpoch() ).utc(); - const durationFromInstanceTillPageLoad = mapValues(dateOnAllInstances, date => - moment.duration(durationEpoch.diff(moment(date).utc())) + log.debug(`DURATION EPOCH is: ${durationEpoch}]`); + + const durationFromInstanceInApiUntilPageLoad = mapValues( + dateOnAllInstancesFromApiResponse, + // time from Alert Instance until pageload (AKA durationEpoch) + date => { + const durationFromApiResuiltToEpoch = moment.duration( + durationEpoch.diff(moment(date).utc()) + ); + // The UI removes milliseconds, so lets do the same in the test so we can compare + return moment.duration({ + hours: durationFromApiResuiltToEpoch.hours(), + minutes: durationFromApiResuiltToEpoch.minutes(), + seconds: durationFromApiResuiltToEpoch.seconds(), + }); + } ); + instancesList .map(alertInstance => ({ id: alertInstance.instance, - duration: alertInstance.duration.split(':').map(part => parseInt(part, 10)), + // time from Alert Instance used to render the list until pageload (AKA durationEpoch) + duration: moment.duration(alertInstance.duration), })) - .map(({ id, duration: [hours, minutes, seconds] }) => ({ - id, - duration: moment.duration({ - hours, - minutes, - seconds, - }), - })) - .forEach(({ id, duration }) => { - // make sure the duration is within a 10 second range which is - // good enough as the alert interval is 1m, so we know it is a fresh value - expect(duration.as('milliseconds')).to.greaterThan( - durationFromInstanceTillPageLoad[id].subtract(1000 * 10).as('milliseconds') + .forEach(({ id, duration: durationAsItAppearsOnList }) => { + log.debug( + `DURATION of ${id} [From UI: ${durationAsItAppearsOnList.as( + 'seconds' + )} seconds] [From API: ${durationFromInstanceInApiUntilPageLoad[id].as( + 'seconds' + )} seconds]` ); - expect(duration.as('milliseconds')).to.lessThan( - durationFromInstanceTillPageLoad[id].add(1000 * 10).as('milliseconds') + + expect(durationFromInstanceInApiUntilPageLoad[id].as('seconds')).to.equal( + durationAsItAppearsOnList.as('seconds') ); }); }); From 452193fdba7c77815472889d1715b1ca341530ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= <alejandro.haro@elastic.co> Date: Mon, 23 Mar 2020 18:49:38 +0000 Subject: [PATCH 030/179] [Telemetry] Server-side Migration to NP (#60485) * [Telemetry] Migration to NP * Telemetry management advanced settings section + fix import paths + dropped support for injectVars * Fix i18nrc paths for telemetry * Move ui_metric mappings to NP registerType * Fixed minor test tweaks * Add README docs (#60443) * Add missing translation * Update the telemetryService config only when authenticated * start method is not a promise anymore * Fix mocha tests * No need to JSON.stringify the API responses * Catch handleOldSettings as we used to do * Deal with the forbidden use case in the optIn API * No need to provide the plugin name in the logger.get(). It is automatically scoped + one missing CallCluster vs. APICaller type replacement * Add empty start method in README.md to show differences with the other approach * Telemetry collection with X-Pack README * Docs update * Allow monitoring collector to send its own ES client * All collections should provide their own ES client * PR feedback * i18n NITs from kibana-platform feedback Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .i18nrc.json | 4 +- .../application_usage/mappings.ts | 36 --- .../application_usage/package.json | 4 - .../telemetry/common/constants.ts | 83 ------ src/legacy/core_plugins/telemetry/index.ts | 153 ----------- .../core_plugins/telemetry/mappings.json | 30 --- .../core_plugins/telemetry/package.json | 4 - .../public/views/management/management.tsx | 49 ---- .../telemetry/server/collection_manager.ts | 249 ----------------- .../core_plugins/telemetry/server/plugin.ts | 70 ----- .../server/routes/telemetry_opt_in.ts | 97 ------- .../server/routes/telemetry_usage_stats.ts | 67 ----- .../telemetry_config/replace_injected_vars.ts | 72 ----- src/legacy/core_plugins/ui_metric/index.ts | 3 - .../core_plugins/ui_metric/mappings.json | 9 - .../index.ts => server/i18n/constants.ts} | 16 +- ...tions_path.js => get_translations_path.ts} | 10 +- src/legacy/server/i18n/{index.js => index.ts} | 30 ++- .../localization/file_integrity.test.mocks.ts | 0 .../i18n}/localization/file_integrity.test.ts | 0 .../i18n}/localization/file_integrity.ts | 0 .../i18n}/localization/index.ts | 0 .../telemetry_localization_collector.test.ts | 5 +- .../telemetry_localization_collector.ts | 29 +- src/legacy/server/kbn_server.d.ts | 2 + src/plugins/data/server/server.api.md | 1 - .../telemetry/README.md | 2 +- src/plugins/telemetry/common/constants.ts | 45 +++- ..._telemetry_allow_changing_opt_in_status.ts | 3 +- .../get_telemetry_failure_details.test.ts | 0 .../get_telemetry_failure_details.ts | 2 +- ...ry_notify_user_about_optin_default.test.ts | 0 ...lemetry_notify_user_about_optin_default.ts | 2 +- .../get_telemetry_opt_in.test.ts | 2 +- .../telemetry_config/get_telemetry_opt_in.ts | 2 +- .../get_telemetry_send_usage_from.test.ts | 2 +- .../get_telemetry_send_usage_from.ts | 2 +- .../common}/telemetry_config/index.ts | 1 - .../common/telemetry_config/types.ts} | 5 +- src/plugins/telemetry/kibana.json | 8 +- .../telemetry/public/components/index.ts | 2 - src/plugins/telemetry/public/index.ts | 7 +- src/plugins/telemetry/public/mocks.ts | 28 +- src/plugins/telemetry/public/plugin.ts | 122 ++++++++- .../public/services/telemetry_service.ts | 53 ++-- .../application_usage/index.test.ts | 11 +- .../collectors/application_usage/index.ts | 0 .../application_usage/saved_objects_types.ts | 59 ++++ .../telemetry_application_usage_collector.ts | 22 +- .../server/collectors/find_all.test.ts | 2 +- .../telemetry/server/collectors/find_all.ts | 0 .../telemetry/server/collectors/index.ts | 2 - .../server/collectors/management/index.ts | 0 .../telemetry_management_collector.ts | 23 +- .../collectors/telemetry_plugin/index.ts | 0 .../telemetry_plugin_collector.ts | 44 +-- .../server/collectors/ui_metric/index.test.ts | 11 +- .../server/collectors/ui_metric/index.ts | 0 .../telemetry_ui_metric_collector.ts | 22 +- .../usage/ensure_deep_object.test.ts | 0 .../collectors/usage/ensure_deep_object.ts | 0 .../server/collectors/usage/index.ts | 0 .../usage/telemetry_usage_collector.test.ts | 21 +- .../usage/telemetry_usage_collector.ts | 19 +- src/plugins/telemetry/server/config.ts | 61 +++++ .../telemetry/server/fetcher.ts | 189 +++++++------ .../handle_old_settings.ts | 18 +- .../server/handle_old_settings/index.ts | 0 .../telemetry/server/index.ts | 29 +- src/plugins/telemetry/server/plugin.ts | 168 ++++++++++++ .../telemetry/server/routes/index.ts | 23 +- .../server/routes/telemetry_opt_in.ts | 101 +++++++ .../server/routes/telemetry_opt_in_stats.ts | 50 ++-- .../server/routes/telemetry_usage_stats.ts | 82 ++++++ .../routes/telemetry_user_has_seen_notice.ts | 30 +-- .../__tests__/get_cluster_info.js | 0 .../__tests__/get_cluster_stats.js | 0 .../__tests__/get_local_stats.js | 9 +- .../server/telemetry_collection/constants.ts | 0 .../telemetry_collection/get_cluster_info.ts | 4 +- .../telemetry_collection/get_cluster_stats.ts | 6 +- .../server/telemetry_collection/get_kibana.ts | 20 +- .../telemetry_collection/get_local_license.ts | 22 +- .../telemetry_collection/get_local_stats.ts | 21 +- .../server/telemetry_collection/index.ts | 3 +- .../register_collection.ts | 10 +- .../get_telemetry_saved_object.test.ts | 4 +- .../get_telemetry_saved_object.ts | 13 +- .../server/telemetry_repository/index.ts | 25 ++ .../update_telemetry_saved_object.ts | 4 +- .../telemetry_collection_manager/README.md | 7 + .../common}/index.ts | 3 +- .../telemetry_collection_manager/kibana.json | 10 + .../server}/encryption/encrypt.test.ts | 0 .../server}/encryption/encrypt.ts | 0 .../server}/encryption/index.ts | 0 .../server}/encryption/telemetry_jwks.ts | 0 .../server/index.ts | 41 +++ .../server/plugin.ts | 253 ++++++++++++++++++ .../server/types.ts | 150 +++++++++++ .../telemetry_management_section/README.md | 5 + .../telemetry_management_section/kibana.json | 10 + .../opt_in_example_flyout.test.tsx.snap | 0 .../public/components/index.ts | 22 ++ .../components/opt_in_example_flyout.test.tsx | 0 .../components/opt_in_example_flyout.tsx | 0 .../telemetry_management_section.tsx | 9 +- .../telemetry_management_section_wrapper.tsx | 40 +++ .../public/index.ts} | 6 +- .../public/plugin.ts | 54 ++++ src/plugins/usage_collection/README.md | 163 ++++++++--- .../server/collector/collector.ts | 5 +- .../server/collector/collector_set.ts | 13 +- src/plugins/usage_collection/server/index.ts | 2 +- src/plugins/usage_collection/server/plugin.ts | 16 +- test/tsconfig.json | 3 +- x-pack/legacy/plugins/monitoring/index.js | 2 - x-pack/legacy/plugins/xpack_main/index.js | 2 - .../register_xpack_collection.ts | 21 -- .../public/application/lib/telemetry.ts | 2 +- x-pack/plugins/monitoring/kibana.json | 4 +- x-pack/plugins/monitoring/server/plugin.ts | 32 ++- .../get_all_stats.test.ts | 60 +++-- .../telemetry_collection/get_all_stats.ts | 19 +- .../get_beats_stats.test.ts | 8 +- .../telemetry_collection/get_beats_stats.ts | 17 +- .../get_cluster_uuids.test.ts | 19 +- .../telemetry_collection/get_cluster_uuids.ts | 25 +- .../telemetry_collection/get_es_stats.test.ts | 18 +- .../telemetry_collection/get_es_stats.ts | 15 +- .../get_high_level_stats.test.ts | 16 +- .../get_high_level_stats.ts | 17 +- .../telemetry_collection/get_kibana_stats.ts | 10 +- .../telemetry_collection/get_licenses.test.ts | 21 +- .../telemetry_collection/get_licenses.ts | 24 +- .../register_monitoring_collection.ts | 20 +- .../telemetry_collection_xpack/README.md | 9 + .../common/index.ts | 8 + .../telemetry_collection_xpack/kibana.json | 10 + .../server/index.ts | 15 ++ .../server/plugin.ts | 31 +++ .../get_stats_with_xpack.test.ts.snap | 0 .../__tests__/get_xpack.js | 0 .../server/telemetry_collection/constants.ts | 0 .../get_stats_with_xpack.test.ts | 55 ++-- .../get_stats_with_xpack.ts | 14 +- .../server/telemetry_collection/get_xpack.ts | 0 .../server/telemetry_collection/index.ts | 2 +- .../telemetry/telemetry_optin_notice_seen.ts | 2 +- 149 files changed, 2128 insertions(+), 1621 deletions(-) delete mode 100644 src/legacy/core_plugins/application_usage/mappings.ts delete mode 100644 src/legacy/core_plugins/application_usage/package.json delete mode 100644 src/legacy/core_plugins/telemetry/common/constants.ts delete mode 100644 src/legacy/core_plugins/telemetry/index.ts delete mode 100644 src/legacy/core_plugins/telemetry/mappings.json delete mode 100644 src/legacy/core_plugins/telemetry/package.json delete mode 100644 src/legacy/core_plugins/telemetry/public/views/management/management.tsx delete mode 100644 src/legacy/core_plugins/telemetry/server/collection_manager.ts delete mode 100644 src/legacy/core_plugins/telemetry/server/plugin.ts delete mode 100644 src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in.ts delete mode 100644 src/legacy/core_plugins/telemetry/server/routes/telemetry_usage_stats.ts delete mode 100644 src/legacy/core_plugins/telemetry/server/telemetry_config/replace_injected_vars.ts delete mode 100644 src/legacy/core_plugins/ui_metric/mappings.json rename src/legacy/{core_plugins/application_usage/index.ts => server/i18n/constants.ts} (66%) rename src/legacy/server/i18n/{get_translations_path.js => get_translations_path.ts} (85%) rename src/legacy/server/i18n/{index.js => index.ts} (62%) rename src/legacy/{core_plugins/telemetry/server/collectors => server/i18n}/localization/file_integrity.test.mocks.ts (100%) rename src/legacy/{core_plugins/telemetry/server/collectors => server/i18n}/localization/file_integrity.test.ts (100%) rename src/legacy/{core_plugins/telemetry/server/collectors => server/i18n}/localization/file_integrity.ts (100%) rename src/legacy/{core_plugins/telemetry/server/collectors => server/i18n}/localization/index.ts (100%) rename src/legacy/{core_plugins/telemetry/server/collectors => server/i18n}/localization/telemetry_localization_collector.test.ts (94%) rename src/legacy/{core_plugins/telemetry/server/collectors => server/i18n}/localization/telemetry_localization_collector.ts (71%) rename src/{legacy/core_plugins => plugins}/telemetry/README.md (81%) rename src/{legacy/core_plugins/telemetry/server => plugins/telemetry/common}/telemetry_config/get_telemetry_allow_changing_opt_in_status.ts (93%) rename src/{legacy/core_plugins/telemetry/server => plugins/telemetry/common}/telemetry_config/get_telemetry_failure_details.test.ts (100%) rename src/{legacy/core_plugins/telemetry/server => plugins/telemetry/common}/telemetry_config/get_telemetry_failure_details.ts (94%) rename src/{legacy/core_plugins/telemetry/server => plugins/telemetry/common}/telemetry_config/get_telemetry_notify_user_about_optin_default.test.ts (100%) rename src/{legacy/core_plugins/telemetry/server => plugins/telemetry/common}/telemetry_config/get_telemetry_notify_user_about_optin_default.ts (94%) rename src/{legacy/core_plugins/telemetry/server => plugins/telemetry/common}/telemetry_config/get_telemetry_opt_in.test.ts (98%) rename src/{legacy/core_plugins/telemetry/server => plugins/telemetry/common}/telemetry_config/get_telemetry_opt_in.ts (97%) rename src/{legacy/core_plugins/telemetry/server => plugins/telemetry/common}/telemetry_config/get_telemetry_send_usage_from.test.ts (96%) rename src/{legacy/core_plugins/telemetry/server => plugins/telemetry/common}/telemetry_config/get_telemetry_send_usage_from.ts (93%) rename src/{legacy/core_plugins/telemetry/server => plugins/telemetry/common}/telemetry_config/index.ts (94%) rename src/{legacy/core_plugins/telemetry/server/telemetry_repository/index.ts => plugins/telemetry/common/telemetry_config/types.ts} (86%) rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/application_usage/index.test.ts (91%) rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/application_usage/index.ts (100%) create mode 100644 src/plugins/telemetry/server/collectors/application_usage/saved_objects_types.ts rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/application_usage/telemetry_application_usage_collector.ts (94%) rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/find_all.test.ts (96%) rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/find_all.ts (100%) rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/index.ts (90%) rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/management/index.ts (100%) rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/management/telemetry_management_collector.ts (73%) rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/telemetry_plugin/index.ts (100%) rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/telemetry_plugin/telemetry_plugin_collector.ts (62%) rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/ui_metric/index.test.ts (87%) rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/ui_metric/index.ts (100%) rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/ui_metric/telemetry_ui_metric_collector.ts (82%) rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/usage/ensure_deep_object.test.ts (100%) rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/usage/ensure_deep_object.ts (100%) rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/usage/index.ts (100%) rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts (89%) rename src/{legacy/core_plugins => plugins}/telemetry/server/collectors/usage/telemetry_usage_collector.ts (87%) create mode 100644 src/plugins/telemetry/server/config.ts rename src/{legacy/core_plugins => plugins}/telemetry/server/fetcher.ts (55%) rename src/{legacy/core_plugins => plugins}/telemetry/server/handle_old_settings/handle_old_settings.ts (74%) rename src/{legacy/core_plugins => plugins}/telemetry/server/handle_old_settings/index.ts (100%) rename src/{legacy/core_plugins => plugins}/telemetry/server/index.ts (60%) create mode 100644 src/plugins/telemetry/server/plugin.ts rename src/{legacy/core_plugins => plugins}/telemetry/server/routes/index.ts (61%) create mode 100644 src/plugins/telemetry/server/routes/telemetry_opt_in.ts rename src/{legacy/core_plugins => plugins}/telemetry/server/routes/telemetry_opt_in_stats.ts (65%) create mode 100644 src/plugins/telemetry/server/routes/telemetry_usage_stats.ts rename src/{legacy/core_plugins => plugins}/telemetry/server/routes/telemetry_user_has_seen_notice.ts (65%) rename src/{legacy/core_plugins => plugins}/telemetry/server/telemetry_collection/__tests__/get_cluster_info.js (100%) rename src/{legacy/core_plugins => plugins}/telemetry/server/telemetry_collection/__tests__/get_cluster_stats.js (100%) rename src/{legacy/core_plugins => plugins}/telemetry/server/telemetry_collection/__tests__/get_local_stats.js (96%) rename src/{legacy/core_plugins => plugins}/telemetry/server/telemetry_collection/constants.ts (100%) rename src/{legacy/core_plugins => plugins}/telemetry/server/telemetry_collection/get_cluster_info.ts (92%) rename src/{legacy/core_plugins => plugins}/telemetry/server/telemetry_collection/get_cluster_stats.ts (86%) rename src/{legacy/core_plugins => plugins}/telemetry/server/telemetry_collection/get_kibana.ts (79%) rename src/{legacy/core_plugins => plugins}/telemetry/server/telemetry_collection/get_local_license.ts (80%) rename src/{legacy/core_plugins => plugins}/telemetry/server/telemetry_collection/get_local_stats.ts (82%) rename src/{legacy/core_plugins => plugins}/telemetry/server/telemetry_collection/index.ts (87%) rename src/{legacy/core_plugins => plugins}/telemetry/server/telemetry_collection/register_collection.ts (86%) rename src/{legacy/core_plugins => plugins}/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts (95%) rename src/{legacy/core_plugins => plugins}/telemetry/server/telemetry_repository/get_telemetry_saved_object.ts (81%) create mode 100644 src/plugins/telemetry/server/telemetry_repository/index.ts rename src/{legacy/core_plugins => plugins}/telemetry/server/telemetry_repository/update_telemetry_saved_object.ts (89%) create mode 100644 src/plugins/telemetry_collection_manager/README.md rename src/{legacy/core_plugins/telemetry/public/views/management => plugins/telemetry_collection_manager/common}/index.ts (87%) create mode 100644 src/plugins/telemetry_collection_manager/kibana.json rename src/{legacy/core_plugins/telemetry/server/collectors => plugins/telemetry_collection_manager/server}/encryption/encrypt.test.ts (100%) rename src/{legacy/core_plugins/telemetry/server/collectors => plugins/telemetry_collection_manager/server}/encryption/encrypt.ts (100%) rename src/{legacy/core_plugins/telemetry/server/collectors => plugins/telemetry_collection_manager/server}/encryption/index.ts (100%) rename src/{legacy/core_plugins/telemetry/server/collectors => plugins/telemetry_collection_manager/server}/encryption/telemetry_jwks.ts (100%) create mode 100644 src/plugins/telemetry_collection_manager/server/index.ts create mode 100644 src/plugins/telemetry_collection_manager/server/plugin.ts create mode 100644 src/plugins/telemetry_collection_manager/server/types.ts create mode 100644 src/plugins/telemetry_management_section/README.md create mode 100644 src/plugins/telemetry_management_section/kibana.json rename src/plugins/{telemetry => telemetry_management_section}/public/components/__snapshots__/opt_in_example_flyout.test.tsx.snap (100%) create mode 100644 src/plugins/telemetry_management_section/public/components/index.ts rename src/plugins/{telemetry => telemetry_management_section}/public/components/opt_in_example_flyout.test.tsx (100%) rename src/plugins/{telemetry => telemetry_management_section}/public/components/opt_in_example_flyout.tsx (100%) rename src/plugins/{telemetry => telemetry_management_section}/public/components/telemetry_management_section.tsx (96%) create mode 100644 src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx rename src/{legacy/server/i18n/constants.js => plugins/telemetry_management_section/public/index.ts} (85%) create mode 100644 src/plugins/telemetry_management_section/public/plugin.ts delete mode 100644 x-pack/legacy/plugins/xpack_main/server/telemetry_collection/register_xpack_collection.ts create mode 100644 x-pack/plugins/telemetry_collection_xpack/README.md create mode 100644 x-pack/plugins/telemetry_collection_xpack/common/index.ts create mode 100644 x-pack/plugins/telemetry_collection_xpack/kibana.json create mode 100644 x-pack/plugins/telemetry_collection_xpack/server/index.ts create mode 100644 x-pack/plugins/telemetry_collection_xpack/server/plugin.ts rename x-pack/{legacy/plugins/xpack_main => plugins/telemetry_collection_xpack}/server/telemetry_collection/__snapshots__/get_stats_with_xpack.test.ts.snap (100%) rename x-pack/{legacy/plugins/xpack_main => plugins/telemetry_collection_xpack}/server/telemetry_collection/__tests__/get_xpack.js (100%) rename x-pack/{legacy/plugins/xpack_main => plugins/telemetry_collection_xpack}/server/telemetry_collection/constants.ts (100%) rename x-pack/{legacy/plugins/xpack_main => plugins/telemetry_collection_xpack}/server/telemetry_collection/get_stats_with_xpack.test.ts (73%) rename x-pack/{legacy/plugins/xpack_main => plugins/telemetry_collection_xpack}/server/telemetry_collection/get_stats_with_xpack.ts (70%) rename x-pack/{legacy/plugins/xpack_main => plugins/telemetry_collection_xpack}/server/telemetry_collection/get_xpack.ts (100%) rename x-pack/{legacy/plugins/xpack_main => plugins/telemetry_collection_xpack}/server/telemetry_collection/index.ts (76%) diff --git a/.i18nrc.json b/.i18nrc.json index bffe99bf3654b..36b28a0f5bd34 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -35,8 +35,8 @@ "server": "src/legacy/server", "statusPage": "src/legacy/core_plugins/status_page", "telemetry": [ - "src/legacy/core_plugins/telemetry", - "src/plugins/telemetry" + "src/plugins/telemetry", + "src/plugins/telemetry_management_section" ], "tileMap": "src/legacy/core_plugins/tile_map", "timelion": ["src/legacy/core_plugins/timelion", "src/legacy/core_plugins/vis_type_timelion", "src/plugins/timelion"], diff --git a/src/legacy/core_plugins/application_usage/mappings.ts b/src/legacy/core_plugins/application_usage/mappings.ts deleted file mode 100644 index 39adc53f7e9ff..0000000000000 --- a/src/legacy/core_plugins/application_usage/mappings.ts +++ /dev/null @@ -1,36 +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. - */ - -export const mappings = { - application_usage_totals: { - properties: { - appId: { type: 'keyword' }, - numberOfClicks: { type: 'long' }, - minutesOnScreen: { type: 'float' }, - }, - }, - application_usage_transactional: { - properties: { - timestamp: { type: 'date' }, - appId: { type: 'keyword' }, - numberOfClicks: { type: 'long' }, - minutesOnScreen: { type: 'float' }, - }, - }, -}; diff --git a/src/legacy/core_plugins/application_usage/package.json b/src/legacy/core_plugins/application_usage/package.json deleted file mode 100644 index 5ab10a2f8d237..0000000000000 --- a/src/legacy/core_plugins/application_usage/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "application_usage", - "version": "kibana" -} \ No newline at end of file diff --git a/src/legacy/core_plugins/telemetry/common/constants.ts b/src/legacy/core_plugins/telemetry/common/constants.ts deleted file mode 100644 index b44bf319e6627..0000000000000 --- a/src/legacy/core_plugins/telemetry/common/constants.ts +++ /dev/null @@ -1,83 +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 { i18n } from '@kbn/i18n'; - -/* - * config options opt into telemetry - * @type {string} - */ -export const CONFIG_TELEMETRY = 'telemetry:optIn'; -/* - * config description for opting into telemetry - * @type {string} - */ -export const getConfigTelemetryDesc = () => { - return i18n.translate('telemetry.telemetryConfigDescription', { - defaultMessage: - 'Help us improve the Elastic Stack by providing usage statistics for basic features. We will not share this data outside of Elastic.', - }); -}; - -/** - * The amount of time, in milliseconds, to wait between reports when enabled. - * - * Currently 24 hours. - * @type {Number} - */ -export const REPORT_INTERVAL_MS = 86400000; - -/** - * Link to the Elastic Telemetry privacy statement. - */ -export const PRIVACY_STATEMENT_URL = `https://www.elastic.co/legal/privacy-statement`; - -/** - * The type name used within the Monitoring index to publish localization stats. - * @type {string} - */ -export const KIBANA_LOCALIZATION_STATS_TYPE = 'localization'; - -/** - * The type name used to publish telemetry plugin stats. - * @type {string} - */ -export const TELEMETRY_STATS_TYPE = 'telemetry'; - -/** - * UI metric usage type - * @type {string} - */ -export const UI_METRIC_USAGE_TYPE = 'ui_metric'; - -/** - * Application Usage type - */ -export const APPLICATION_USAGE_TYPE = 'application_usage'; - -/** - * Link to Advanced Settings. - */ -export const PATH_TO_ADVANCED_SETTINGS = 'kibana#/management/kibana/settings'; - -/** - * The type name used within the Monitoring index to publish management stats. - * @type {string} - */ -export const KIBANA_STACK_MANAGEMENT_STATS_TYPE = 'stack_management'; diff --git a/src/legacy/core_plugins/telemetry/index.ts b/src/legacy/core_plugins/telemetry/index.ts deleted file mode 100644 index 1e88e7d65cffd..0000000000000 --- a/src/legacy/core_plugins/telemetry/index.ts +++ /dev/null @@ -1,153 +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 * as Rx from 'rxjs'; -import { resolve } from 'path'; -import JoiNamespace from 'joi'; -import { Server } from 'hapi'; -import { PluginInitializerContext } from 'src/core/server'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { getConfigPath } from '../../../core/server/path'; -// @ts-ignore -import mappings from './mappings.json'; -import { - telemetryPlugin, - replaceTelemetryInjectedVars, - FetcherTask, - PluginsSetup, - handleOldSettings, -} from './server'; - -const ENDPOINT_VERSION = 'v2'; - -const telemetry = (kibana: any) => { - return new kibana.Plugin({ - id: 'telemetry', - configPrefix: 'telemetry', - publicDir: resolve(__dirname, 'public'), - require: ['elasticsearch'], - config(Joi: typeof JoiNamespace) { - return Joi.object({ - enabled: Joi.boolean().default(true), - allowChangingOptInStatus: Joi.boolean().default(true), - optIn: Joi.when('allowChangingOptInStatus', { - is: false, - then: Joi.valid(true).default(true), - otherwise: Joi.boolean().default(true), - }), - // `config` is used internally and not intended to be set - config: Joi.string().default(getConfigPath()), - banner: Joi.boolean().default(true), - url: Joi.when('$dev', { - is: true, - then: Joi.string().default( - `https://telemetry-staging.elastic.co/xpack/${ENDPOINT_VERSION}/send` - ), - otherwise: Joi.string().default( - `https://telemetry.elastic.co/xpack/${ENDPOINT_VERSION}/send` - ), - }), - optInStatusUrl: Joi.when('$dev', { - is: true, - then: Joi.string().default( - `https://telemetry-staging.elastic.co/opt_in_status/${ENDPOINT_VERSION}/send` - ), - otherwise: Joi.string().default( - `https://telemetry.elastic.co/opt_in_status/${ENDPOINT_VERSION}/send` - ), - }), - sendUsageFrom: Joi.string() - .allow(['server', 'browser']) - .default('browser'), - }).default(); - }, - uiExports: { - managementSections: ['plugins/telemetry/views/management'], - savedObjectSchemas: { - telemetry: { - isNamespaceAgnostic: true, - }, - }, - async replaceInjectedVars(originalInjectedVars: any, request: any, server: any) { - const telemetryInjectedVars = await replaceTelemetryInjectedVars(request, server); - return Object.assign({}, originalInjectedVars, telemetryInjectedVars); - }, - injectDefaultVars(server: Server) { - const config = server.config(); - return { - telemetryEnabled: config.get('telemetry.enabled'), - telemetryUrl: config.get('telemetry.url'), - telemetryBanner: - config.get('telemetry.allowChangingOptInStatus') !== false && - config.get('telemetry.banner'), - telemetryOptedIn: config.get('telemetry.optIn'), - telemetryOptInStatusUrl: config.get('telemetry.optInStatusUrl'), - allowChangingOptInStatus: config.get('telemetry.allowChangingOptInStatus'), - telemetrySendUsageFrom: config.get('telemetry.sendUsageFrom'), - telemetryNotifyUserAboutOptInDefault: false, - }; - }, - mappings, - }, - postInit(server: Server) { - const fetcherTask = new FetcherTask(server); - fetcherTask.start(); - }, - async init(server: Server) { - const { usageCollection } = server.newPlatform.setup.plugins; - const initializerContext = { - env: { - packageInfo: { - version: server.config().get('pkg.version'), - }, - }, - config: { - create() { - const config = server.config(); - return Rx.of({ - enabled: config.get('telemetry.enabled'), - optIn: config.get('telemetry.optIn'), - config: config.get('telemetry.config'), - banner: config.get('telemetry.banner'), - url: config.get('telemetry.url'), - allowChangingOptInStatus: config.get('telemetry.allowChangingOptInStatus'), - }); - }, - }, - } as PluginInitializerContext; - - try { - await handleOldSettings(server); - } catch (err) { - server.log(['warning', 'telemetry'], 'Unable to update legacy telemetry configs.'); - } - - const pluginsSetup: PluginsSetup = { - usageCollection, - }; - - const npPlugin = telemetryPlugin(initializerContext); - await npPlugin.setup(server.newPlatform.setup.core, pluginsSetup, server); - await npPlugin.start(server.newPlatform.start.core); - }, - }); -}; - -// eslint-disable-next-line import/no-default-export -export default telemetry; diff --git a/src/legacy/core_plugins/telemetry/mappings.json b/src/legacy/core_plugins/telemetry/mappings.json deleted file mode 100644 index fa9cc93d6363a..0000000000000 --- a/src/legacy/core_plugins/telemetry/mappings.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "telemetry": { - "properties": { - "enabled": { - "type": "boolean" - }, - "sendUsageFrom": { - "ignore_above": 256, - "type": "keyword" - }, - "lastReported": { - "type": "date" - }, - "lastVersionChecked": { - "ignore_above": 256, - "type": "keyword" - }, - "userHasSeenNotice": { - "type": "boolean" - }, - "reportFailureCount": { - "type": "integer" - }, - "reportFailureVersion": { - "ignore_above": 256, - "type": "keyword" - } - } - } -} diff --git a/src/legacy/core_plugins/telemetry/package.json b/src/legacy/core_plugins/telemetry/package.json deleted file mode 100644 index 979e68cce742f..0000000000000 --- a/src/legacy/core_plugins/telemetry/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "telemetry", - "version": "kibana" -} diff --git a/src/legacy/core_plugins/telemetry/public/views/management/management.tsx b/src/legacy/core_plugins/telemetry/public/views/management/management.tsx deleted file mode 100644 index c8ae410e0aa57..0000000000000 --- a/src/legacy/core_plugins/telemetry/public/views/management/management.tsx +++ /dev/null @@ -1,49 +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 React from 'react'; -import routes from 'ui/routes'; -import { npStart, npSetup } from 'ui/new_platform'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { TelemetryManagementSection } from '../../../../../../plugins/telemetry/public/components'; - -routes.defaults(/\/management/, { - resolve: { - telemetryManagementSection() { - const { telemetry } = npStart.plugins as any; - const { advancedSettings } = npSetup.plugins as any; - - if (telemetry && advancedSettings) { - const componentRegistry = advancedSettings.component; - const Component = (props: any) => ( - <TelemetryManagementSection - showAppliesSettingMessage={true} - telemetryService={telemetry.telemetryService} - {...props} - /> - ); - - componentRegistry.register( - componentRegistry.componentType.PAGE_FOOTER_COMPONENT, - Component, - true - ); - } - }, - }, -}); diff --git a/src/legacy/core_plugins/telemetry/server/collection_manager.ts b/src/legacy/core_plugins/telemetry/server/collection_manager.ts deleted file mode 100644 index ebac4bede85bb..0000000000000 --- a/src/legacy/core_plugins/telemetry/server/collection_manager.ts +++ /dev/null @@ -1,249 +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 { encryptTelemetry } from './collectors'; -import { CallCluster } from '../../elasticsearch'; -import { UsageCollectionSetup } from '../../../../plugins/usage_collection/server'; -import { Cluster } from '../../elasticsearch'; -import { ESLicense } from './telemetry_collection/get_local_license'; - -export type EncryptedStatsGetterConfig = { unencrypted: false } & { - server: any; - start: string; - end: string; -}; - -export type UnencryptedStatsGetterConfig = { unencrypted: true } & { - req: any; - start: string; - end: string; -}; - -export interface ClusterDetails { - clusterUuid: string; -} - -export interface StatsCollectionConfig { - usageCollection: UsageCollectionSetup; - callCluster: CallCluster; - server: any; - start: string | number; - end: string | number; -} - -export interface BasicStatsPayload { - timestamp: string; - cluster_uuid: string; - cluster_name: string; - version: string; - cluster_stats: object; - collection?: string; - stack_stats: object; -} - -export type StatsGetterConfig = UnencryptedStatsGetterConfig | EncryptedStatsGetterConfig; -export type ClusterDetailsGetter = (config: StatsCollectionConfig) => Promise<ClusterDetails[]>; -export type StatsGetter<T extends BasicStatsPayload = BasicStatsPayload> = ( - clustersDetails: ClusterDetails[], - config: StatsCollectionConfig -) => Promise<T[]>; -export type LicenseGetter = ( - clustersDetails: ClusterDetails[], - config: StatsCollectionConfig -) => Promise<{ [clusterUuid: string]: ESLicense | undefined }>; - -interface CollectionConfig<T extends BasicStatsPayload> { - title: string; - priority: number; - esCluster: string | Cluster; - statsGetter: StatsGetter<T>; - clusterDetailsGetter: ClusterDetailsGetter; - licenseGetter: LicenseGetter; -} -interface Collection { - statsGetter: StatsGetter; - licenseGetter: LicenseGetter; - clusterDetailsGetter: ClusterDetailsGetter; - esCluster: string | Cluster; - title: string; -} - -export class TelemetryCollectionManager { - private usageGetterMethodPriority = -1; - private collections: Collection[] = []; - - public setCollection = <T extends BasicStatsPayload>(collectionConfig: CollectionConfig<T>) => { - const { - title, - priority, - esCluster, - statsGetter, - clusterDetailsGetter, - licenseGetter, - } = collectionConfig; - - if (typeof priority !== 'number') { - throw new Error('priority must be set.'); - } - if (priority === this.usageGetterMethodPriority) { - throw new Error(`A Usage Getter with the same priority is already set.`); - } - - if (priority > this.usageGetterMethodPriority) { - if (!statsGetter) { - throw Error('Stats getter method not set.'); - } - if (!esCluster) { - throw Error('esCluster name must be set for the getCluster method.'); - } - if (!clusterDetailsGetter) { - throw Error('Cluster UUIds method is not set.'); - } - if (!licenseGetter) { - throw Error('License getter method not set.'); - } - - this.collections.unshift({ - licenseGetter, - statsGetter, - clusterDetailsGetter, - esCluster, - title, - }); - this.usageGetterMethodPriority = priority; - } - }; - - private getStatsCollectionConfig = async ( - collection: Collection, - config: StatsGetterConfig - ): Promise<StatsCollectionConfig> => { - const { start, end } = config; - const server = config.unencrypted ? config.req.server : config.server; - const { callWithRequest, callWithInternalUser } = - typeof collection.esCluster === 'string' - ? server.plugins.elasticsearch.getCluster(collection.esCluster) - : collection.esCluster; - const callCluster = config.unencrypted - ? (...args: any[]) => callWithRequest(config.req, ...args) - : callWithInternalUser; - - const { usageCollection } = server.newPlatform.setup.plugins; - return { server, callCluster, start, end, usageCollection }; - }; - - private getOptInStatsForCollection = async ( - collection: Collection, - optInStatus: boolean, - statsCollectionConfig: StatsCollectionConfig - ) => { - const clustersDetails = await collection.clusterDetailsGetter(statsCollectionConfig); - return clustersDetails.map(({ clusterUuid }) => ({ - cluster_uuid: clusterUuid, - opt_in_status: optInStatus, - })); - }; - - private getUsageForCollection = async ( - collection: Collection, - statsCollectionConfig: StatsCollectionConfig - ) => { - const clustersDetails = await collection.clusterDetailsGetter(statsCollectionConfig); - - if (clustersDetails.length === 0) { - // don't bother doing a further lookup, try next collection. - return; - } - - const [stats, licenses] = await Promise.all([ - collection.statsGetter(clustersDetails, statsCollectionConfig), - collection.licenseGetter(clustersDetails, statsCollectionConfig), - ]); - - return stats.map(stat => { - const license = licenses[stat.cluster_uuid]; - return { - ...(license ? { license } : {}), - ...stat, - collectionSource: collection.title, - }; - }); - }; - - public getOptInStats = async (optInStatus: boolean, config: StatsGetterConfig) => { - for (const collection of this.collections) { - const statsCollectionConfig = await this.getStatsCollectionConfig(collection, config); - try { - const optInStats = await this.getOptInStatsForCollection( - collection, - optInStatus, - statsCollectionConfig - ); - if (optInStats && optInStats.length) { - statsCollectionConfig.server.log( - ['debug', 'telemetry', 'collection'], - `Got Opt In stats using ${collection.title} collection.` - ); - if (config.unencrypted) { - return optInStats; - } - const isDev = statsCollectionConfig.server.config().get('env.dev'); - return encryptTelemetry(optInStats, isDev); - } - } catch (err) { - statsCollectionConfig.server.log( - ['debu', 'telemetry', 'collection'], - `Failed to collect any opt in stats with registered collections.` - ); - // swallow error to try next collection; - } - } - - return []; - }; - public getStats = async (config: StatsGetterConfig) => { - for (const collection of this.collections) { - const statsCollectionConfig = await this.getStatsCollectionConfig(collection, config); - try { - const usageData = await this.getUsageForCollection(collection, statsCollectionConfig); - if (usageData && usageData.length) { - statsCollectionConfig.server.log( - ['debug', 'telemetry', 'collection'], - `Got Usage using ${collection.title} collection.` - ); - if (config.unencrypted) { - return usageData; - } - const isDev = statsCollectionConfig.server.config().get('env.dev'); - return encryptTelemetry(usageData, isDev); - } - } catch (err) { - statsCollectionConfig.server.log( - ['debug', 'telemetry', 'collection'], - `Failed to collect any usage with registered collections.` - ); - // swallow error to try next collection; - } - } - - return []; - }; -} - -export const telemetryCollectionManager = new TelemetryCollectionManager(); diff --git a/src/legacy/core_plugins/telemetry/server/plugin.ts b/src/legacy/core_plugins/telemetry/server/plugin.ts deleted file mode 100644 index 0b9f0526988c8..0000000000000 --- a/src/legacy/core_plugins/telemetry/server/plugin.ts +++ /dev/null @@ -1,70 +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 { - CoreSetup, - PluginInitializerContext, - ISavedObjectsRepository, - CoreStart, -} from 'src/core/server'; -import { Server } from 'hapi'; -import { registerRoutes } from './routes'; -import { registerCollection } from './telemetry_collection'; -import { UsageCollectionSetup } from '../../../../plugins/usage_collection/server'; -import { - registerUiMetricUsageCollector, - registerTelemetryUsageCollector, - registerLocalizationUsageCollector, - registerTelemetryPluginUsageCollector, - registerManagementUsageCollector, - registerApplicationUsageCollector, -} from './collectors'; - -export interface PluginsSetup { - usageCollection: UsageCollectionSetup; -} - -export class TelemetryPlugin { - private readonly currentKibanaVersion: string; - private savedObjectsClient?: ISavedObjectsRepository; - - constructor(initializerContext: PluginInitializerContext) { - this.currentKibanaVersion = initializerContext.env.packageInfo.version; - } - - public setup(core: CoreSetup, { usageCollection }: PluginsSetup, server: Server) { - const currentKibanaVersion = this.currentKibanaVersion; - - registerCollection(); - registerRoutes({ core, currentKibanaVersion, server }); - - const getSavedObjectsClient = () => this.savedObjectsClient; - - registerTelemetryPluginUsageCollector(usageCollection, server); - registerLocalizationUsageCollector(usageCollection, server); - registerTelemetryUsageCollector(usageCollection, server); - registerUiMetricUsageCollector(usageCollection, getSavedObjectsClient); - registerManagementUsageCollector(usageCollection, server); - registerApplicationUsageCollector(usageCollection, getSavedObjectsClient); - } - - public start({ savedObjects }: CoreStart) { - this.savedObjectsClient = savedObjects.createInternalRepository(); - } -} diff --git a/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in.ts b/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in.ts deleted file mode 100644 index ccbc28f6cbadb..0000000000000 --- a/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in.ts +++ /dev/null @@ -1,97 +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 Joi from 'joi'; -import moment from 'moment'; -import { boomify } from 'boom'; -import { CoreSetup } from 'src/core/server'; -import { Legacy } from 'kibana'; -import { getTelemetryAllowChangingOptInStatus } from '../telemetry_config'; -import { sendTelemetryOptInStatus } from './telemetry_opt_in_stats'; - -import { - TelemetrySavedObjectAttributes, - updateTelemetrySavedObject, -} from '../telemetry_repository'; - -interface RegisterOptInRoutesParams { - core: CoreSetup; - currentKibanaVersion: string; - server: Legacy.Server; -} - -export function registerTelemetryOptInRoutes({ - server, - currentKibanaVersion, -}: RegisterOptInRoutesParams) { - server.route({ - method: 'POST', - path: '/api/telemetry/v2/optIn', - options: { - validate: { - payload: Joi.object({ - enabled: Joi.bool().required(), - }), - }, - }, - handler: async (req: any, h: any) => { - try { - const newOptInStatus = req.payload.enabled; - const attributes: TelemetrySavedObjectAttributes = { - enabled: newOptInStatus, - lastVersionChecked: currentKibanaVersion, - }; - const config = req.server.config(); - const savedObjectsClient = req.getSavedObjectsClient(); - const configTelemetryAllowChangingOptInStatus = config.get( - 'telemetry.allowChangingOptInStatus' - ); - - const allowChangingOptInStatus = getTelemetryAllowChangingOptInStatus({ - telemetrySavedObject: savedObjectsClient, - configTelemetryAllowChangingOptInStatus, - }); - if (!allowChangingOptInStatus) { - return h.response({ error: 'Not allowed to change Opt-in Status.' }).code(400); - } - - const sendUsageFrom = config.get('telemetry.sendUsageFrom'); - if (sendUsageFrom === 'server') { - const optInStatusUrl = config.get('telemetry.optInStatusUrl'); - await sendTelemetryOptInStatus( - { optInStatusUrl, newOptInStatus }, - { - start: moment() - .subtract(20, 'minutes') - .toISOString(), - end: moment().toISOString(), - server: req.server, - unencrypted: false, - } - ); - } - - await updateTelemetrySavedObject(savedObjectsClient, attributes); - return h.response({}).code(200); - } catch (err) { - return boomify(err); - } - }, - }); -} diff --git a/src/legacy/core_plugins/telemetry/server/routes/telemetry_usage_stats.ts b/src/legacy/core_plugins/telemetry/server/routes/telemetry_usage_stats.ts deleted file mode 100644 index ee3241b0dc2ea..0000000000000 --- a/src/legacy/core_plugins/telemetry/server/routes/telemetry_usage_stats.ts +++ /dev/null @@ -1,67 +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 Joi from 'joi'; -import { boomify } from 'boom'; -import { Legacy } from 'kibana'; -import { telemetryCollectionManager } from '../collection_manager'; - -export function registerTelemetryUsageStatsRoutes(server: Legacy.Server) { - server.route({ - method: 'POST', - path: '/api/telemetry/v2/clusters/_stats', - options: { - validate: { - payload: Joi.object({ - unencrypted: Joi.bool(), - timeRange: Joi.object({ - min: Joi.date().required(), - max: Joi.date().required(), - }).required(), - }), - }, - }, - handler: async (req: any, h: any) => { - const config = req.server.config(); - const start = req.payload.timeRange.min; - const end = req.payload.timeRange.max; - const unencrypted = req.payload.unencrypted; - - try { - return await telemetryCollectionManager.getStats({ - unencrypted, - server, - req, - start, - end, - }); - } catch (err) { - const isDev = config.get('env.dev'); - if (isDev) { - // don't ignore errors when running in dev mode - return boomify(err, { statusCode: err.status || 500 }); - } else { - const statusCode = unencrypted && err.status === 403 ? 403 : 200; - // ignore errors and return empty set - return h.response([]).code(statusCode); - } - } - }, - }); -} diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/replace_injected_vars.ts b/src/legacy/core_plugins/telemetry/server/telemetry_config/replace_injected_vars.ts deleted file mode 100644 index f09ee8623afac..0000000000000 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/replace_injected_vars.ts +++ /dev/null @@ -1,72 +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 { getTelemetrySavedObject } from '../telemetry_repository'; -import { getTelemetryOptIn } from './get_telemetry_opt_in'; -import { getTelemetrySendUsageFrom } from './get_telemetry_send_usage_from'; -import { getTelemetryAllowChangingOptInStatus } from './get_telemetry_allow_changing_opt_in_status'; -import { getNotifyUserAboutOptInDefault } from './get_telemetry_notify_user_about_optin_default'; - -export async function replaceTelemetryInjectedVars(request: any, server: any) { - const config = server.config(); - const configTelemetrySendUsageFrom = config.get('telemetry.sendUsageFrom'); - const configTelemetryOptIn = config.get('telemetry.optIn'); - const configTelemetryAllowChangingOptInStatus = config.get('telemetry.allowChangingOptInStatus'); - const isRequestingApplication = request.path.startsWith('/app'); - - // Prevent interstitial screens (such as the space selector) from prompting for telemetry - if (!isRequestingApplication) { - return { - telemetryOptedIn: false, - }; - } - - const currentKibanaVersion = config.get('pkg.version'); - const savedObjectsClient = server.savedObjects.getScopedSavedObjectsClient(request); - const telemetrySavedObject = await getTelemetrySavedObject(savedObjectsClient); - const allowChangingOptInStatus = getTelemetryAllowChangingOptInStatus({ - configTelemetryAllowChangingOptInStatus, - telemetrySavedObject, - }); - - const telemetryOptedIn = getTelemetryOptIn({ - configTelemetryOptIn, - allowChangingOptInStatus, - telemetrySavedObject, - currentKibanaVersion, - }); - - const telemetrySendUsageFrom = getTelemetrySendUsageFrom({ - configTelemetrySendUsageFrom, - telemetrySavedObject, - }); - - const telemetryNotifyUserAboutOptInDefault = getNotifyUserAboutOptInDefault({ - telemetrySavedObject, - allowChangingOptInStatus, - configTelemetryOptIn, - telemetryOptedIn, - }); - - return { - telemetryOptedIn, - telemetrySendUsageFrom, - telemetryNotifyUserAboutOptInDefault, - }; -} diff --git a/src/legacy/core_plugins/ui_metric/index.ts b/src/legacy/core_plugins/ui_metric/index.ts index 5a4a0ebf1a632..2e5be3d7b0a39 100644 --- a/src/legacy/core_plugins/ui_metric/index.ts +++ b/src/legacy/core_plugins/ui_metric/index.ts @@ -25,9 +25,6 @@ export default function(kibana: any) { id: 'ui_metric', require: ['kibana', 'elasticsearch'], publicDir: resolve(__dirname, 'public'), - uiExports: { - mappings: require('./mappings.json'), - }, init() {}, }); } diff --git a/src/legacy/core_plugins/ui_metric/mappings.json b/src/legacy/core_plugins/ui_metric/mappings.json deleted file mode 100644 index 113e37e60e48b..0000000000000 --- a/src/legacy/core_plugins/ui_metric/mappings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "ui-metric": { - "properties": { - "count": { - "type": "integer" - } - } - } -} diff --git a/src/legacy/core_plugins/application_usage/index.ts b/src/legacy/server/i18n/constants.ts similarity index 66% rename from src/legacy/core_plugins/application_usage/index.ts rename to src/legacy/server/i18n/constants.ts index 752d6eaa19bb0..96fa420d4c6e1 100644 --- a/src/legacy/core_plugins/application_usage/index.ts +++ b/src/legacy/server/i18n/constants.ts @@ -17,15 +17,9 @@ * under the License. */ -import { Legacy } from '../../../../kibana'; -import { mappings } from './mappings'; +export const I18N_RC = '.i18nrc.json'; -// eslint-disable-next-line import/no-default-export -export default function ApplicationUsagePlugin(kibana: any) { - const config: Legacy.PluginSpecOptions = { - id: 'application_usage', - uiExports: { mappings }, // Needed to define the mappings for the SavedObjects - }; - - return new kibana.Plugin(config); -} +/** + * The type name used within the Monitoring index to publish localization stats. + */ +export const KIBANA_LOCALIZATION_STATS_TYPE = 'localization'; diff --git a/src/legacy/server/i18n/get_translations_path.js b/src/legacy/server/i18n/get_translations_path.ts similarity index 85% rename from src/legacy/server/i18n/get_translations_path.js rename to src/legacy/server/i18n/get_translations_path.ts index 6ac3e75e1d4a8..ac7c61dcf8543 100644 --- a/src/legacy/server/i18n/get_translations_path.js +++ b/src/legacy/server/i18n/get_translations_path.ts @@ -24,16 +24,20 @@ import globby from 'globby'; const readFileAsync = promisify(readFile); -export async function getTranslationPaths({ cwd, glob }) { +interface I18NRCFileStructure { + translations?: string[]; +} + +export async function getTranslationPaths({ cwd, glob }: { cwd: string; glob: string }) { const entries = await globby(glob, { cwd }); - const translationPaths = []; + const translationPaths: string[] = []; for (const entry of entries) { const entryFullPath = resolve(cwd, entry); const pluginBasePath = dirname(entryFullPath); try { const content = await readFileAsync(entryFullPath, 'utf8'); - const { translations } = JSON.parse(content); + const { translations } = JSON.parse(content) as I18NRCFileStructure; if (translations && translations.length) { translations.forEach(translation => { const translationFullPath = resolve(pluginBasePath, translation); diff --git a/src/legacy/server/i18n/index.js b/src/legacy/server/i18n/index.ts similarity index 62% rename from src/legacy/server/i18n/index.js rename to src/legacy/server/i18n/index.ts index e7fa5d5f6a5c0..9902aaa1e8914 100644 --- a/src/legacy/server/i18n/index.js +++ b/src/legacy/server/i18n/index.ts @@ -19,30 +19,35 @@ import { i18n, i18nLoader } from '@kbn/i18n'; import { basename } from 'path'; +import { Server } from 'hapi'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { fromRoot } from '../../../core/server/utils'; import { getTranslationPaths } from './get_translations_path'; import { I18N_RC } from './constants'; +import KbnServer, { KibanaConfig } from '../kbn_server'; +import { registerLocalizationUsageCollector } from './localization'; -export async function i18nMixin(kbnServer, server, config) { - const locale = config.get('i18n.locale'); +export async function i18nMixin(kbnServer: KbnServer, server: Server, config: KibanaConfig) { + const locale = config.get('i18n.locale') as string; const translationPaths = await Promise.all([ getTranslationPaths({ cwd: fromRoot('.'), glob: I18N_RC, }), - ...config.get('plugins.paths').map(cwd => getTranslationPaths({ cwd, glob: I18N_RC })), - ...config - .get('plugins.scanDirs') - .map(cwd => getTranslationPaths({ cwd, glob: `*/${I18N_RC}` })), + ...(config.get('plugins.paths') as string[]).map(cwd => + getTranslationPaths({ cwd, glob: I18N_RC }) + ), + ...(config.get('plugins.scanDirs') as string[]).map(cwd => + getTranslationPaths({ cwd, glob: `*/${I18N_RC}` }) + ), getTranslationPaths({ cwd: fromRoot('../kibana-extra'), glob: `*/${I18N_RC}`, }), ]); - const currentTranslationPaths = [] + const currentTranslationPaths = ([] as string[]) .concat(...translationPaths) .filter(translationPath => basename(translationPath, '.json') === locale); i18nLoader.registerTranslationFiles(currentTranslationPaths); @@ -55,5 +60,14 @@ export async function i18nMixin(kbnServer, server, config) { }) ); - server.decorate('server', 'getTranslationsFilePaths', () => currentTranslationPaths); + const getTranslationsFilePaths = () => currentTranslationPaths; + + server.decorate('server', 'getTranslationsFilePaths', getTranslationsFilePaths); + + if (kbnServer.newPlatform.setup.plugins.usageCollection) { + registerLocalizationUsageCollector(kbnServer.newPlatform.setup.plugins.usageCollection, { + getLocale: () => config.get('i18n.locale') as string, + getTranslationsFilePaths, + }); + } } diff --git a/src/legacy/core_plugins/telemetry/server/collectors/localization/file_integrity.test.mocks.ts b/src/legacy/server/i18n/localization/file_integrity.test.mocks.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/localization/file_integrity.test.mocks.ts rename to src/legacy/server/i18n/localization/file_integrity.test.mocks.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/localization/file_integrity.test.ts b/src/legacy/server/i18n/localization/file_integrity.test.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/localization/file_integrity.test.ts rename to src/legacy/server/i18n/localization/file_integrity.test.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/localization/file_integrity.ts b/src/legacy/server/i18n/localization/file_integrity.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/localization/file_integrity.ts rename to src/legacy/server/i18n/localization/file_integrity.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/localization/index.ts b/src/legacy/server/i18n/localization/index.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/localization/index.ts rename to src/legacy/server/i18n/localization/index.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/localization/telemetry_localization_collector.test.ts b/src/legacy/server/i18n/localization/telemetry_localization_collector.test.ts similarity index 94% rename from src/legacy/core_plugins/telemetry/server/collectors/localization/telemetry_localization_collector.test.ts rename to src/legacy/server/i18n/localization/telemetry_localization_collector.test.ts index eec5cc8a065e4..cbe23da87c767 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/localization/telemetry_localization_collector.test.ts +++ b/src/legacy/server/i18n/localization/telemetry_localization_collector.test.ts @@ -22,16 +22,17 @@ interface TranslationsMock { } const createI18nLoaderMock = (translations: TranslationsMock) => { - return { + return ({ getTranslationsByLocale() { return { messages: translations, }; }, - }; + } as unknown) as typeof i18nLoader; }; import { getTranslationCount } from './telemetry_localization_collector'; +import { i18nLoader } from '@kbn/i18n'; describe('getTranslationCount', () => { it('returns 0 if no translations registered', async () => { diff --git a/src/legacy/core_plugins/telemetry/server/collectors/localization/telemetry_localization_collector.ts b/src/legacy/server/i18n/localization/telemetry_localization_collector.ts similarity index 71% rename from src/legacy/core_plugins/telemetry/server/collectors/localization/telemetry_localization_collector.ts rename to src/legacy/server/i18n/localization/telemetry_localization_collector.ts index 191565187be14..89566dfd4ef68 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/localization/telemetry_localization_collector.ts +++ b/src/legacy/server/i18n/localization/telemetry_localization_collector.ts @@ -19,25 +19,36 @@ import { i18nLoader } from '@kbn/i18n'; import { size } from 'lodash'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { getIntegrityHashes, Integrities } from './file_integrity'; -import { KIBANA_LOCALIZATION_STATS_TYPE } from '../../../common/constants'; -import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; +import { KIBANA_LOCALIZATION_STATS_TYPE } from '../constants'; + export interface UsageStats { locale: string; integrities: Integrities; labelsCount?: number; } -export async function getTranslationCount(loader: any, locale: string): Promise<number> { +export interface LocalizationUsageCollectorHelpers { + getLocale: () => string; + getTranslationsFilePaths: () => string[]; +} + +export async function getTranslationCount( + loader: typeof i18nLoader, + locale: string +): Promise<number> { const translations = await loader.getTranslationsByLocale(locale); return size(translations.messages); } -export function createCollectorFetch(server: any) { +export function createCollectorFetch({ + getLocale, + getTranslationsFilePaths, +}: LocalizationUsageCollectorHelpers) { return async function fetchUsageStats(): Promise<UsageStats> { - const config = server.config(); - const locale: string = config.get('i18n.locale'); - const translationFilePaths: string[] = server.getTranslationsFilePaths(); + const locale = getLocale(); + const translationFilePaths: string[] = getTranslationsFilePaths(); const [labelsCount, integrities] = await Promise.all([ getTranslationCount(i18nLoader, locale), @@ -54,12 +65,12 @@ export function createCollectorFetch(server: any) { export function registerLocalizationUsageCollector( usageCollection: UsageCollectionSetup, - server: any + helpers: LocalizationUsageCollectorHelpers ) { const collector = usageCollection.makeUsageCollector({ type: KIBANA_LOCALIZATION_STATS_TYPE, isReady: () => true, - fetch: createCollectorFetch(server), + fetch: createCollectorFetch(helpers), }); usageCollection.registerCollector(collector); diff --git a/src/legacy/server/kbn_server.d.ts b/src/legacy/server/kbn_server.d.ts index 9952b345fa06f..d222dbb550f11 100644 --- a/src/legacy/server/kbn_server.d.ts +++ b/src/legacy/server/kbn_server.d.ts @@ -20,6 +20,7 @@ import { ResponseObject, Server } from 'hapi'; import { UnwrapPromise } from '@kbn/utility-types'; +import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; import { ConfigService, CoreSetup, @@ -104,6 +105,7 @@ type KbnMixinFunc = (kbnServer: KbnServer, server: Server, config: any) => Promi export interface PluginsSetup { usageCollection: UsageCollectionSetup; + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup; home: HomeServerPluginSetup; [key: string]: object; } diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 5c231cdc05e61..1abc74fe07ccc 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -7,7 +7,6 @@ import { APICaller as APICaller_2 } from 'kibana/server'; import Boom from 'boom'; import { BulkIndexDocumentsParams } from 'elasticsearch'; -import { CallCluster as CallCluster_2 } from 'src/legacy/core_plugins/elasticsearch'; import { CatAliasesParams } from 'elasticsearch'; import { CatAllocationParams } from 'elasticsearch'; import { CatCommonParams } from 'elasticsearch'; diff --git a/src/legacy/core_plugins/telemetry/README.md b/src/plugins/telemetry/README.md similarity index 81% rename from src/legacy/core_plugins/telemetry/README.md rename to src/plugins/telemetry/README.md index 830c08f8e8bed..196d596fb784f 100644 --- a/src/legacy/core_plugins/telemetry/README.md +++ b/src/plugins/telemetry/README.md @@ -6,4 +6,4 @@ Telemetry allows Kibana features to have usage tracked in the wild. The general 2. Sending a payload of usage data up to Elastic's telemetry cluster. 3. Viewing usage data in the Kibana instance of the telemetry cluster (Viewing). -This plugin is responsible for sending usage data to the telemetry cluster. For collecting usage data, use +This plugin is responsible for sending usage data to the telemetry cluster. For collecting usage data, use the [`usageCollection` plugin](../usage_collection/README.md) diff --git a/src/plugins/telemetry/common/constants.ts b/src/plugins/telemetry/common/constants.ts index 7b7694ed9aed7..babd009143c5e 100644 --- a/src/plugins/telemetry/common/constants.ts +++ b/src/plugins/telemetry/common/constants.ts @@ -17,13 +17,31 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; + +/** + * config options opt into telemetry + */ +export const CONFIG_TELEMETRY = 'telemetry:optIn'; + +/** + * config description for opting into telemetry + */ +export const getConfigTelemetryDesc = () => { + // Can't find where it's used but copying it over from the legacy code just in case... + return i18n.translate('telemetry.telemetryConfigDescription', { + defaultMessage: + 'Help us improve the Elastic Stack by providing usage statistics for basic features. We will not share this data outside of Elastic.', + }); +}; + /** * The amount of time, in milliseconds, to wait between reports when enabled. * Currently 24 hours. */ export const REPORT_INTERVAL_MS = 86400000; -/* +/** * Key for the localStorage service */ export const LOCALSTORAGE_KEY = 'telemetry.data'; @@ -37,3 +55,28 @@ export const PATH_TO_ADVANCED_SETTINGS = 'kibana#/management/kibana/settings'; * Link to the Elastic Telemetry privacy statement. */ export const PRIVACY_STATEMENT_URL = `https://www.elastic.co/legal/privacy-statement`; + +/** + * The type name used to publish telemetry plugin stats. + */ +export const TELEMETRY_STATS_TYPE = 'telemetry'; + +/** + * The endpoint version when hitting the remote telemetry service + */ +export const ENDPOINT_VERSION = 'v2'; + +/** + * UI metric usage type + */ +export const UI_METRIC_USAGE_TYPE = 'ui_metric'; + +/** + * Application Usage type + */ +export const APPLICATION_USAGE_TYPE = 'application_usage'; + +/** + * The type name used within the Monitoring index to publish management stats. + */ +export const KIBANA_STACK_MANAGEMENT_STATS_TYPE = 'stack_management'; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_allow_changing_opt_in_status.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_allow_changing_opt_in_status.ts similarity index 93% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_allow_changing_opt_in_status.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_allow_changing_opt_in_status.ts index 9fa4fbc5e0227..d7dcfd606b6ac 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_allow_changing_opt_in_status.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_allow_changing_opt_in_status.ts @@ -16,7 +16,8 @@ * specific language governing permissions and limitations * under the License. */ -import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object'; + +import { TelemetrySavedObject } from './types'; interface GetTelemetryAllowChangingOptInStatus { configTelemetryAllowChangingOptInStatus: boolean; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_failure_details.test.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.test.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_failure_details.test.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.test.ts diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_failure_details.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.ts similarity index 94% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_failure_details.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.ts index 2952fa96a5cf3..c23ec42be56c4 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_failure_details.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object'; +import { TelemetrySavedObject } from './types'; interface GetTelemetryFailureDetailsConfig { telemetrySavedObject: TelemetrySavedObject; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.test.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_notify_user_about_optin_default.test.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.test.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_notify_user_about_optin_default.test.ts diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_notify_user_about_optin_default.ts similarity index 94% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_notify_user_about_optin_default.ts index 8ef3bd8388ecb..19bd1974ffba1 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_notify_user_about_optin_default.ts @@ -17,7 +17,7 @@ * under the License. */ -import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object'; +import { TelemetrySavedObject } from './types'; interface NotifyOpts { allowChangingOptInStatus: boolean; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_opt_in.test.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.test.ts similarity index 98% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_opt_in.test.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.test.ts index efc4a020e0ff0..da44abd35517c 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_opt_in.test.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.test.ts @@ -18,7 +18,7 @@ */ import { getTelemetryOptIn } from './get_telemetry_opt_in'; -import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object'; +import { TelemetrySavedObject } from './types'; describe('getTelemetryOptIn', () => { it('returns null when saved object not found', () => { diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_opt_in.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.ts similarity index 97% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_opt_in.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.ts index d83ffdf69b576..7beb5415ad7b1 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_opt_in.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.ts @@ -18,7 +18,7 @@ */ import semver from 'semver'; -import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object'; +import { TelemetrySavedObject } from './types'; interface GetTelemetryOptInConfig { telemetrySavedObject: TelemetrySavedObject; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_send_usage_from.test.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_send_usage_from.test.ts similarity index 96% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_send_usage_from.test.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_send_usage_from.test.ts index 69868a97a931d..2cf68f0abedea 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_send_usage_from.test.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_send_usage_from.test.ts @@ -18,7 +18,7 @@ */ import { getTelemetrySendUsageFrom } from './get_telemetry_send_usage_from'; -import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object'; +import { TelemetrySavedObject } from './types'; describe('getTelemetrySendUsageFrom', () => { it('returns kibana.yml config when saved object not found', () => { diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_send_usage_from.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_send_usage_from.ts similarity index 93% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_send_usage_from.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_send_usage_from.ts index 9e4ae14b6097c..78dc1d877c47f 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_send_usage_from.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_send_usage_from.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object'; +import { TelemetrySavedObject } from './types'; interface GetTelemetryUsageFetcherConfig { configTelemetrySendUsageFrom: 'browser' | 'server'; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/index.ts b/src/plugins/telemetry/common/telemetry_config/index.ts similarity index 94% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/index.ts rename to src/plugins/telemetry/common/telemetry_config/index.ts index bf9855ce7538e..51eb4dd16105e 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/index.ts +++ b/src/plugins/telemetry/common/telemetry_config/index.ts @@ -17,7 +17,6 @@ * under the License. */ -export { replaceTelemetryInjectedVars } from './replace_injected_vars'; export { getTelemetryOptIn } from './get_telemetry_opt_in'; export { getTelemetrySendUsageFrom } from './get_telemetry_send_usage_from'; export { getTelemetryAllowChangingOptInStatus } from './get_telemetry_allow_changing_opt_in_status'; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_repository/index.ts b/src/plugins/telemetry/common/telemetry_config/types.ts similarity index 86% rename from src/legacy/core_plugins/telemetry/server/telemetry_repository/index.ts rename to src/plugins/telemetry/common/telemetry_config/types.ts index f1735d1bb2866..7ab37e9544164 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_repository/index.ts +++ b/src/plugins/telemetry/common/telemetry_config/types.ts @@ -17,9 +17,6 @@ * under the License. */ -export { getTelemetrySavedObject, TelemetrySavedObject } from './get_telemetry_saved_object'; -export { updateTelemetrySavedObject } from './update_telemetry_saved_object'; - export interface TelemetrySavedObjectAttributes { enabled?: boolean | null; lastVersionChecked?: string; @@ -30,3 +27,5 @@ export interface TelemetrySavedObjectAttributes { reportFailureCount?: number; reportFailureVersion?: string; } + +export type TelemetrySavedObject = TelemetrySavedObjectAttributes | null | false; diff --git a/src/plugins/telemetry/kibana.json b/src/plugins/telemetry/kibana.json index 3a28149276c3e..f623f4f2a565d 100644 --- a/src/plugins/telemetry/kibana.json +++ b/src/plugins/telemetry/kibana.json @@ -1,6 +1,10 @@ { "id": "telemetry", "version": "kibana", - "server": false, - "ui": true + "server": true, + "ui": true, + "requiredPlugins": [ + "telemetryCollectionManager", + "usageCollection" + ] } diff --git a/src/plugins/telemetry/public/components/index.ts b/src/plugins/telemetry/public/components/index.ts index f4341154f527a..8fda181b2ed93 100644 --- a/src/plugins/telemetry/public/components/index.ts +++ b/src/plugins/telemetry/public/components/index.ts @@ -17,6 +17,4 @@ * under the License. */ -export { OptInExampleFlyout } from './opt_in_example_flyout'; -export { TelemetryManagementSection } from './telemetry_management_section'; export { OptedInNoticeBanner } from './opted_in_notice_banner'; diff --git a/src/plugins/telemetry/public/index.ts b/src/plugins/telemetry/public/index.ts index 2f86d7749bb9b..665c89ba2bffa 100644 --- a/src/plugins/telemetry/public/index.ts +++ b/src/plugins/telemetry/public/index.ts @@ -17,9 +17,10 @@ * under the License. */ -import { TelemetryPlugin } from './plugin'; +import { PluginInitializerContext } from 'kibana/public'; +import { TelemetryPlugin, TelemetryPluginConfig } from './plugin'; export { TelemetryPluginStart, TelemetryPluginSetup } from './plugin'; -export function plugin() { - return new TelemetryPlugin(); +export function plugin(initializerContext: PluginInitializerContext<TelemetryPluginConfig>) { + return new TelemetryPlugin(initializerContext); } diff --git a/src/plugins/telemetry/public/mocks.ts b/src/plugins/telemetry/public/mocks.ts index 93dc13c327509..4e0f02242961a 100644 --- a/src/plugins/telemetry/public/mocks.ts +++ b/src/plugins/telemetry/public/mocks.ts @@ -23,8 +23,6 @@ import { overlayServiceMock } from '../../../core/public/overlays/overlay_servic import { httpServiceMock } from '../../../core/public/http/http_service.mock'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { notificationServiceMock } from '../../../core/public/notifications/notifications_service.mock'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { injectedMetadataServiceMock } from '../../../core/public/injected_metadata/injected_metadata_service.mock'; import { TelemetryService } from './services/telemetry_service'; import { TelemetryNotifications } from './services/telemetry_notifications/telemetry_notifications'; import { TelemetryPluginStart } from './plugin'; @@ -32,23 +30,19 @@ import { TelemetryPluginStart } from './plugin'; export function mockTelemetryService({ reportOptInStatusChange, }: { reportOptInStatusChange?: boolean } = {}) { - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getInjectedVar.mockImplementation((key: string) => { - switch (key) { - case 'telemetryNotifyUserAboutOptInDefault': - return true; - case 'allowChangingOptInStatus': - return true; - case 'telemetryOptedIn': - return true; - default: { - throw Error(`Unhandled getInjectedVar key "${key}".`); - } - } - }); + const config = { + enabled: true, + url: 'http://localhost', + optInStatusUrl: 'http://localhost', + sendUsageFrom: 'browser' as const, + optIn: true, + banner: true, + allowChangingOptInStatus: true, + telemetryNotifyUserAboutOptInDefault: true, + }; return new TelemetryService({ - injectedMetadata, + config, http: httpServiceMock.createStartContract(), notifications: notificationServiceMock.createStartContract(), reportOptInStatusChange, diff --git a/src/plugins/telemetry/public/plugin.ts b/src/plugins/telemetry/public/plugin.ts index 9cfb4ca1ec395..86679227059e6 100644 --- a/src/plugins/telemetry/public/plugin.ts +++ b/src/plugins/telemetry/public/plugin.ts @@ -16,9 +16,27 @@ * specific language governing permissions and limitations * under the License. */ -import { Plugin, CoreStart, CoreSetup, HttpStart } from '../../../core/public'; + +import { + Plugin, + CoreStart, + CoreSetup, + HttpStart, + PluginInitializerContext, + SavedObjectsClientContract, +} from '../../../core/public'; import { TelemetrySender, TelemetryService, TelemetryNotifications } from './services'; +import { + TelemetrySavedObjectAttributes, + TelemetrySavedObject, +} from '../common/telemetry_config/types'; +import { + getTelemetryAllowChangingOptInStatus, + getTelemetryOptIn, + getTelemetrySendUsageFrom, +} from '../common/telemetry_config'; +import { getNotifyUserAboutOptInDefault } from '../common/telemetry_config/get_telemetry_notify_user_about_optin_default'; export interface TelemetryPluginSetup { telemetryService: TelemetryService; @@ -29,17 +47,32 @@ export interface TelemetryPluginStart { telemetryNotifications: TelemetryNotifications; } +export interface TelemetryPluginConfig { + enabled: boolean; + url: string; + banner: boolean; + allowChangingOptInStatus: boolean; + optIn: boolean | null; + optInStatusUrl: string; + sendUsageFrom: 'browser' | 'server'; + telemetryNotifyUserAboutOptInDefault?: boolean; +} + export class TelemetryPlugin implements Plugin<TelemetryPluginSetup, TelemetryPluginStart> { + private readonly currentKibanaVersion: string; + private readonly config: TelemetryPluginConfig; private telemetrySender?: TelemetrySender; private telemetryNotifications?: TelemetryNotifications; private telemetryService?: TelemetryService; - public setup({ http, injectedMetadata, notifications }: CoreSetup): TelemetryPluginSetup { - this.telemetryService = new TelemetryService({ - http, - injectedMetadata, - notifications, - }); + constructor(initializerContext: PluginInitializerContext<TelemetryPluginConfig>) { + this.currentKibanaVersion = initializerContext.env.packageInfo.version; + this.config = initializerContext.config.get(); + } + + public setup({ http, notifications }: CoreSetup): TelemetryPluginSetup { + const config = this.config; + this.telemetryService = new TelemetryService({ config, http, notifications }); this.telemetrySender = new TelemetrySender(this.telemetryService); @@ -48,24 +81,29 @@ export class TelemetryPlugin implements Plugin<TelemetryPluginSetup, TelemetryPl }; } - public start({ injectedMetadata, http, overlays, application }: CoreStart): TelemetryPluginStart { + public start({ http, overlays, application, savedObjects }: CoreStart): TelemetryPluginStart { if (!this.telemetryService) { throw Error('Telemetry plugin failed to initialize properly.'); } - const telemetryBanner = injectedMetadata.getInjectedVar('telemetryBanner') as boolean; - this.telemetryNotifications = new TelemetryNotifications({ overlays, telemetryService: this.telemetryService, }); - application.currentAppId$.subscribe(appId => { + application.currentAppId$.subscribe(async () => { const isUnauthenticated = this.getIsUnauthenticated(http); if (isUnauthenticated) { return; } + // Update the telemetry config based as a mix of the config files and saved objects + const telemetrySavedObject = await this.getTelemetrySavedObject(savedObjects.client); + const updatedConfig = await this.updateConfigsBasedOnSavedObjects(telemetrySavedObject); + this.telemetryService!.config = updatedConfig; + + const telemetryBanner = updatedConfig.banner; + this.maybeStartTelemetryPoller(); if (telemetryBanner) { this.maybeShowOptedInNotificationBanner(); @@ -111,4 +149,66 @@ export class TelemetryPlugin implements Plugin<TelemetryPluginSetup, TelemetryPl this.telemetryNotifications.renderOptInBanner(); } } + + private async updateConfigsBasedOnSavedObjects( + telemetrySavedObject: TelemetrySavedObject + ): Promise<TelemetryPluginConfig> { + const configTelemetrySendUsageFrom = this.config.sendUsageFrom; + const configTelemetryOptIn = this.config.optIn as boolean; + const configTelemetryAllowChangingOptInStatus = this.config.allowChangingOptInStatus; + + const currentKibanaVersion = this.currentKibanaVersion; + + const allowChangingOptInStatus = getTelemetryAllowChangingOptInStatus({ + configTelemetryAllowChangingOptInStatus, + telemetrySavedObject, + }); + + const optIn = getTelemetryOptIn({ + configTelemetryOptIn, + allowChangingOptInStatus, + telemetrySavedObject, + currentKibanaVersion, + }); + + const sendUsageFrom = getTelemetrySendUsageFrom({ + configTelemetrySendUsageFrom, + telemetrySavedObject, + }); + + const telemetryNotifyUserAboutOptInDefault = getNotifyUserAboutOptInDefault({ + telemetrySavedObject, + allowChangingOptInStatus, + configTelemetryOptIn, + telemetryOptedIn: optIn, + }); + + return { + ...this.config, + optIn, + sendUsageFrom, + telemetryNotifyUserAboutOptInDefault, + }; + } + + private async getTelemetrySavedObject(savedObjectsClient: SavedObjectsClientContract) { + try { + const { attributes } = await savedObjectsClient.get<TelemetrySavedObjectAttributes>( + 'telemetry', + 'telemetry' + ); + return attributes; + } catch (error) { + const errorCode = error[Symbol('SavedObjectsClientErrorCode')]; + if (errorCode === 'SavedObjectsClient/notFound') { + return null; + } + + if (errorCode === 'SavedObjectsClient/forbidden') { + return false; + } + + throw error; + } + } } diff --git a/src/plugins/telemetry/public/services/telemetry_service.ts b/src/plugins/telemetry/public/services/telemetry_service.ts index cb91451bd8ef4..cac4e3fdf5f50 100644 --- a/src/plugins/telemetry/public/services/telemetry_service.ts +++ b/src/plugins/telemetry/public/services/telemetry_service.ts @@ -20,62 +20,75 @@ import moment from 'moment'; import { i18n } from '@kbn/i18n'; import { CoreStart } from 'kibana/public'; +import { TelemetryPluginConfig } from '../plugin'; interface TelemetryServiceConstructor { + config: TelemetryPluginConfig; http: CoreStart['http']; - injectedMetadata: CoreStart['injectedMetadata']; notifications: CoreStart['notifications']; reportOptInStatusChange?: boolean; } export class TelemetryService { private readonly http: CoreStart['http']; - private readonly injectedMetadata: CoreStart['injectedMetadata']; private readonly reportOptInStatusChange: boolean; private readonly notifications: CoreStart['notifications']; - private isOptedIn: boolean | null; - private userHasSeenOptedInNotice: boolean; + private readonly defaultConfig: TelemetryPluginConfig; + private updatedConfig?: TelemetryPluginConfig; constructor({ + config, http, - injectedMetadata, notifications, reportOptInStatusChange = true, }: TelemetryServiceConstructor) { - const isOptedIn = injectedMetadata.getInjectedVar('telemetryOptedIn') as boolean | null; - const userHasSeenOptedInNotice = injectedMetadata.getInjectedVar( - 'telemetryNotifyUserAboutOptInDefault' - ) as boolean; + this.defaultConfig = config; this.reportOptInStatusChange = reportOptInStatusChange; - this.injectedMetadata = injectedMetadata; this.notifications = notifications; this.http = http; + } + + public set config(updatedConfig: TelemetryPluginConfig) { + this.updatedConfig = updatedConfig; + } + + public get config() { + return { ...this.defaultConfig, ...this.updatedConfig }; + } + + public get isOptedIn() { + return this.config.optIn; + } + + public set isOptedIn(optIn) { + this.config = { ...this.config, optIn }; + } + + public get userHasSeenOptedInNotice() { + return this.config.telemetryNotifyUserAboutOptInDefault; + } - this.isOptedIn = isOptedIn; - this.userHasSeenOptedInNotice = userHasSeenOptedInNotice; + public set userHasSeenOptedInNotice(telemetryNotifyUserAboutOptInDefault) { + this.config = { ...this.config, telemetryNotifyUserAboutOptInDefault }; } public getCanChangeOptInStatus = () => { - const allowChangingOptInStatus = this.injectedMetadata.getInjectedVar( - 'allowChangingOptInStatus' - ) as boolean; + const allowChangingOptInStatus = this.config.allowChangingOptInStatus; return allowChangingOptInStatus; }; public getOptInStatusUrl = () => { - const telemetryOptInStatusUrl = this.injectedMetadata.getInjectedVar( - 'telemetryOptInStatusUrl' - ) as string; + const telemetryOptInStatusUrl = this.config.optInStatusUrl; return telemetryOptInStatusUrl; }; public getTelemetryUrl = () => { - const telemetryUrl = this.injectedMetadata.getInjectedVar('telemetryUrl') as string; + const telemetryUrl = this.config.url; return telemetryUrl; }; public getUserHasSeenOptedInNotice = () => { - return this.userHasSeenOptedInNotice; + return this.config.telemetryNotifyUserAboutOptInDefault || false; }; public getIsOptedIn = () => { diff --git a/src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.test.ts b/src/plugins/telemetry/server/collectors/application_usage/index.test.ts similarity index 91% rename from src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.test.ts rename to src/plugins/telemetry/server/collectors/application_usage/index.test.ts index 1a64100bda692..5a8fa71363ba7 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.test.ts +++ b/src/plugins/telemetry/server/collectors/application_usage/index.test.ts @@ -17,10 +17,10 @@ * under the License. */ -import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; -import { savedObjectsRepositoryMock } from '../../../../../../core/server/mocks'; +import { UsageCollectionSetup } from '../../../../../plugins/usage_collection/server'; +import { savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { CollectorOptions } from '../../../../../../plugins/usage_collection/server/collector/collector'; +import { CollectorOptions } from '../../../../../plugins/usage_collection/server/collector/collector'; import { registerApplicationUsageCollector } from './'; import { @@ -40,9 +40,12 @@ describe('telemetry_application_usage', () => { } as any; const getUsageCollector = jest.fn(); + const registerType = jest.fn(); const callCluster = jest.fn(); - beforeAll(() => registerApplicationUsageCollector(usageCollectionMock, getUsageCollector)); + beforeAll(() => + registerApplicationUsageCollector(usageCollectionMock, registerType, getUsageCollector) + ); afterAll(() => jest.clearAllTimers()); test('registered collector is set', () => { diff --git a/src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.ts b/src/plugins/telemetry/server/collectors/application_usage/index.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.ts rename to src/plugins/telemetry/server/collectors/application_usage/index.ts diff --git a/src/plugins/telemetry/server/collectors/application_usage/saved_objects_types.ts b/src/plugins/telemetry/server/collectors/application_usage/saved_objects_types.ts new file mode 100644 index 0000000000000..9f997ab7b5df3 --- /dev/null +++ b/src/plugins/telemetry/server/collectors/application_usage/saved_objects_types.ts @@ -0,0 +1,59 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectAttributes, SavedObjectsServiceSetup } from 'kibana/server'; + +export interface ApplicationUsageTotal extends SavedObjectAttributes { + appId: string; + minutesOnScreen: number; + numberOfClicks: number; +} + +export interface ApplicationUsageTransactional extends ApplicationUsageTotal { + timestamp: string; +} + +export function registerMappings(registerType: SavedObjectsServiceSetup['registerType']) { + registerType({ + name: 'application_usage_totals', + hidden: false, + namespaceAgnostic: true, + mappings: { + properties: { + appId: { type: 'keyword' }, + numberOfClicks: { type: 'long' }, + minutesOnScreen: { type: 'float' }, + }, + }, + }); + + registerType({ + name: 'application_usage_transactional', + hidden: false, + namespaceAgnostic: true, + mappings: { + properties: { + timestamp: { type: 'date' }, + appId: { type: 'keyword' }, + numberOfClicks: { type: 'long' }, + minutesOnScreen: { type: 'float' }, + }, + }, + }); +} diff --git a/src/legacy/core_plugins/telemetry/server/collectors/application_usage/telemetry_application_usage_collector.ts b/src/plugins/telemetry/server/collectors/application_usage/telemetry_application_usage_collector.ts similarity index 94% rename from src/legacy/core_plugins/telemetry/server/collectors/application_usage/telemetry_application_usage_collector.ts rename to src/plugins/telemetry/server/collectors/application_usage/telemetry_application_usage_collector.ts index 5c862686a37d9..f52687038bbbc 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/application_usage/telemetry_application_usage_collector.ts +++ b/src/plugins/telemetry/server/collectors/application_usage/telemetry_application_usage_collector.ts @@ -18,10 +18,15 @@ */ import moment from 'moment'; +import { ISavedObjectsRepository, SavedObjectsServiceSetup } from 'kibana/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { APPLICATION_USAGE_TYPE } from '../../../common/constants'; -import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; -import { ISavedObjectsRepository, SavedObjectAttributes } from '../../../../../../core/server'; import { findAll } from '../find_all'; +import { + ApplicationUsageTotal, + ApplicationUsageTransactional, + registerMappings, +} from './saved_objects_types'; /** * Roll indices every 24h @@ -36,16 +41,6 @@ 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'; -interface ApplicationUsageTotal extends SavedObjectAttributes { - appId: string; - minutesOnScreen: number; - numberOfClicks: number; -} - -interface ApplicationUsageTransactional extends ApplicationUsageTotal { - timestamp: string; -} - interface ApplicationUsageTelemetryReport { [appId: string]: { clicks_total: number; @@ -61,8 +56,11 @@ interface ApplicationUsageTelemetryReport { export function registerApplicationUsageCollector( usageCollection: UsageCollectionSetup, + registerType: SavedObjectsServiceSetup['registerType'], getSavedObjectsClient: () => ISavedObjectsRepository | undefined ) { + registerMappings(registerType); + const collector = usageCollection.makeUsageCollector({ type: APPLICATION_USAGE_TYPE, isReady: () => typeof getSavedObjectsClient() !== 'undefined', diff --git a/src/legacy/core_plugins/telemetry/server/collectors/find_all.test.ts b/src/plugins/telemetry/server/collectors/find_all.test.ts similarity index 96% rename from src/legacy/core_plugins/telemetry/server/collectors/find_all.test.ts rename to src/plugins/telemetry/server/collectors/find_all.test.ts index 012cda395bc6c..a62c74c0c0838 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/find_all.test.ts +++ b/src/plugins/telemetry/server/collectors/find_all.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; +import { savedObjectsRepositoryMock } from '../../../../core/server/mocks'; import { findAll } from './find_all'; diff --git a/src/legacy/core_plugins/telemetry/server/collectors/find_all.ts b/src/plugins/telemetry/server/collectors/find_all.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/find_all.ts rename to src/plugins/telemetry/server/collectors/find_all.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/index.ts b/src/plugins/telemetry/server/collectors/index.ts similarity index 90% rename from src/legacy/core_plugins/telemetry/server/collectors/index.ts rename to src/plugins/telemetry/server/collectors/index.ts index 6cb7a38b6414f..6eeda080bb3ab 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/index.ts +++ b/src/plugins/telemetry/server/collectors/index.ts @@ -17,10 +17,8 @@ * under the License. */ -export { encryptTelemetry } from './encryption'; export { registerTelemetryUsageCollector } from './usage'; export { registerUiMetricUsageCollector } from './ui_metric'; -export { registerLocalizationUsageCollector } from './localization'; export { registerTelemetryPluginUsageCollector } from './telemetry_plugin'; export { registerManagementUsageCollector } from './management'; export { registerApplicationUsageCollector } from './application_usage'; diff --git a/src/legacy/core_plugins/telemetry/server/collectors/management/index.ts b/src/plugins/telemetry/server/collectors/management/index.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/management/index.ts rename to src/plugins/telemetry/server/collectors/management/index.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/management/telemetry_management_collector.ts b/src/plugins/telemetry/server/collectors/management/telemetry_management_collector.ts similarity index 73% rename from src/legacy/core_plugins/telemetry/server/collectors/management/telemetry_management_collector.ts rename to src/plugins/telemetry/server/collectors/management/telemetry_management_collector.ts index 481b1e9af2a79..7dc4ca64e6bc3 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/management/telemetry_management_collector.ts +++ b/src/plugins/telemetry/server/collectors/management/telemetry_management_collector.ts @@ -17,11 +17,10 @@ * under the License. */ -import { Server } from 'hapi'; import { size } from 'lodash'; +import { IUiSettingsClient } from 'kibana/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { KIBANA_STACK_MANAGEMENT_STATS_TYPE } from '../../../common/constants'; -import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; -import { SavedObjectsClient } from '../../../../../../core/server'; export type UsageStats = Record<string, any>; @@ -30,12 +29,12 @@ export async function getTranslationCount(loader: any, locale: string): Promise< return size(translations.messages); } -export function createCollectorFetch(server: Server) { - return async function fetchUsageStats(): Promise<UsageStats> { - const internalRepo = server.newPlatform.start.core.savedObjects.createInternalRepository(); - const uiSettingsClient = server.newPlatform.start.core.uiSettings.asScopedToClient( - new SavedObjectsClient(internalRepo) - ); +export function createCollectorFetch(getUiSettingsClient: () => IUiSettingsClient | undefined) { + return async function fetchUsageStats(): Promise<UsageStats | undefined> { + const uiSettingsClient = getUiSettingsClient(); + if (!uiSettingsClient) { + return; + } const user = await uiSettingsClient.getUserProvided(); const modifiedEntries = Object.keys(user) @@ -51,12 +50,12 @@ export function createCollectorFetch(server: Server) { export function registerManagementUsageCollector( usageCollection: UsageCollectionSetup, - server: any + getUiSettingsClient: () => IUiSettingsClient | undefined ) { const collector = usageCollection.makeUsageCollector({ type: KIBANA_STACK_MANAGEMENT_STATS_TYPE, - isReady: () => true, - fetch: createCollectorFetch(server), + isReady: () => typeof getUiSettingsClient() !== 'undefined', + fetch: createCollectorFetch(getUiSettingsClient), }); usageCollection.registerCollector(collector); diff --git a/src/legacy/core_plugins/telemetry/server/collectors/telemetry_plugin/index.ts b/src/plugins/telemetry/server/collectors/telemetry_plugin/index.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/telemetry_plugin/index.ts rename to src/plugins/telemetry/server/collectors/telemetry_plugin/index.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/telemetry_plugin/telemetry_plugin_collector.ts b/src/plugins/telemetry/server/collectors/telemetry_plugin/telemetry_plugin_collector.ts similarity index 62% rename from src/legacy/core_plugins/telemetry/server/collectors/telemetry_plugin/telemetry_plugin_collector.ts rename to src/plugins/telemetry/server/collectors/telemetry_plugin/telemetry_plugin_collector.ts index 5e25538cbad80..ab90935266d69 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/telemetry_plugin/telemetry_plugin_collector.ts +++ b/src/plugins/telemetry/server/collectors/telemetry_plugin/telemetry_plugin_collector.ts @@ -17,10 +17,14 @@ * under the License. */ +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { ISavedObjectsRepository, SavedObjectsClient } from '../../../../../core/server'; import { TELEMETRY_STATS_TYPE } from '../../../common/constants'; import { getTelemetrySavedObject, TelemetrySavedObject } from '../../telemetry_repository'; -import { getTelemetryOptIn, getTelemetrySendUsageFrom } from '../../telemetry_config'; -import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; +import { getTelemetryOptIn, getTelemetrySendUsageFrom } from '../../../common/telemetry_config'; +import { UsageCollectionSetup } from '../../../../usage_collection/server'; +import { TelemetryConfigType } from '../../config'; export interface TelemetryUsageStats { opt_in_status?: boolean | null; @@ -28,21 +32,31 @@ export interface TelemetryUsageStats { last_reported?: number; } -export function createCollectorFetch(server: any) { +export interface TelemetryPluginUsageCollectorOptions { + currentKibanaVersion: string; + config$: Observable<TelemetryConfigType>; + getSavedObjectsClient: () => ISavedObjectsRepository | undefined; +} + +export function createCollectorFetch({ + currentKibanaVersion, + config$, + getSavedObjectsClient, +}: TelemetryPluginUsageCollectorOptions) { return async function fetchUsageStats(): Promise<TelemetryUsageStats> { - const config = server.config(); - const configTelemetrySendUsageFrom = config.get('telemetry.sendUsageFrom'); - const allowChangingOptInStatus = config.get('telemetry.allowChangingOptInStatus'); - const configTelemetryOptIn = config.get('telemetry.optIn'); - const currentKibanaVersion = config.get('pkg.version'); + const { sendUsageFrom, allowChangingOptInStatus, optIn = null } = await config$ + .pipe(take(1)) + .toPromise(); + const configTelemetrySendUsageFrom = sendUsageFrom; + const configTelemetryOptIn = optIn; let telemetrySavedObject: TelemetrySavedObject = {}; try { - const { getSavedObjectsRepository } = server.savedObjects; - const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin'); - const internalRepository = getSavedObjectsRepository(callWithInternalUser); - telemetrySavedObject = await getTelemetrySavedObject(internalRepository); + const internalRepository = getSavedObjectsClient()!; + telemetrySavedObject = await getTelemetrySavedObject( + new SavedObjectsClient(internalRepository) + ); } catch (err) { // no-op } @@ -65,12 +79,12 @@ export function createCollectorFetch(server: any) { export function registerTelemetryPluginUsageCollector( usageCollection: UsageCollectionSetup, - server: any + options: TelemetryPluginUsageCollectorOptions ) { const collector = usageCollection.makeUsageCollector({ type: TELEMETRY_STATS_TYPE, - isReady: () => true, - fetch: createCollectorFetch(server), + isReady: () => typeof options.getSavedObjectsClient() !== 'undefined', + fetch: createCollectorFetch(options), }); usageCollection.registerCollector(collector); diff --git a/src/legacy/core_plugins/telemetry/server/collectors/ui_metric/index.test.ts b/src/plugins/telemetry/server/collectors/ui_metric/index.test.ts similarity index 87% rename from src/legacy/core_plugins/telemetry/server/collectors/ui_metric/index.test.ts rename to src/plugins/telemetry/server/collectors/ui_metric/index.test.ts index ddb58a7d09bbd..d6667a6384a1f 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/ui_metric/index.test.ts +++ b/src/plugins/telemetry/server/collectors/ui_metric/index.test.ts @@ -17,10 +17,10 @@ * under the License. */ -import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; -import { savedObjectsRepositoryMock } from '../../../../../../core/server/mocks'; +import { UsageCollectionSetup } from '../../../../../plugins/usage_collection/server'; +import { savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { CollectorOptions } from '../../../../../../plugins/usage_collection/server/collector/collector'; +import { CollectorOptions } from '../../../../../plugins/usage_collection/server/collector/collector'; import { registerUiMetricUsageCollector } from './'; @@ -33,9 +33,12 @@ describe('telemetry_ui_metric', () => { } as any; const getUsageCollector = jest.fn(); + const registerType = jest.fn(); const callCluster = jest.fn(); - beforeAll(() => registerUiMetricUsageCollector(usageCollectionMock, getUsageCollector)); + beforeAll(() => + registerUiMetricUsageCollector(usageCollectionMock, registerType, getUsageCollector) + ); test('registered collector is set', () => { expect(collector).not.toBeUndefined(); diff --git a/src/legacy/core_plugins/telemetry/server/collectors/ui_metric/index.ts b/src/plugins/telemetry/server/collectors/ui_metric/index.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/ui_metric/index.ts rename to src/plugins/telemetry/server/collectors/ui_metric/index.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/ui_metric/telemetry_ui_metric_collector.ts b/src/plugins/telemetry/server/collectors/ui_metric/telemetry_ui_metric_collector.ts similarity index 82% rename from src/legacy/core_plugins/telemetry/server/collectors/ui_metric/telemetry_ui_metric_collector.ts rename to src/plugins/telemetry/server/collectors/ui_metric/telemetry_ui_metric_collector.ts index a7b6850b0b20a..3f6e1836cac7d 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/ui_metric/telemetry_ui_metric_collector.ts +++ b/src/plugins/telemetry/server/collectors/ui_metric/telemetry_ui_metric_collector.ts @@ -17,9 +17,13 @@ * under the License. */ -import { ISavedObjectsRepository, SavedObjectAttributes } from 'kibana/server'; +import { + ISavedObjectsRepository, + SavedObjectAttributes, + SavedObjectsServiceSetup, +} from 'kibana/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { UI_METRIC_USAGE_TYPE } from '../../../common/constants'; -import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; import { findAll } from '../find_all'; interface UIMetricsSavedObjects extends SavedObjectAttributes { @@ -28,8 +32,22 @@ interface UIMetricsSavedObjects extends SavedObjectAttributes { export function registerUiMetricUsageCollector( usageCollection: UsageCollectionSetup, + registerType: SavedObjectsServiceSetup['registerType'], getSavedObjectsClient: () => ISavedObjectsRepository | undefined ) { + registerType({ + name: 'ui-metric', + hidden: false, + namespaceAgnostic: true, + mappings: { + properties: { + count: { + type: 'integer', + }, + }, + }, + }); + const collector = usageCollection.makeUsageCollector({ type: UI_METRIC_USAGE_TYPE, fetch: async () => { diff --git a/src/legacy/core_plugins/telemetry/server/collectors/usage/ensure_deep_object.test.ts b/src/plugins/telemetry/server/collectors/usage/ensure_deep_object.test.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/usage/ensure_deep_object.test.ts rename to src/plugins/telemetry/server/collectors/usage/ensure_deep_object.test.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/usage/ensure_deep_object.ts b/src/plugins/telemetry/server/collectors/usage/ensure_deep_object.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/usage/ensure_deep_object.ts rename to src/plugins/telemetry/server/collectors/usage/ensure_deep_object.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/usage/index.ts b/src/plugins/telemetry/server/collectors/usage/index.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/usage/index.ts rename to src/plugins/telemetry/server/collectors/usage/index.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts b/src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts similarity index 89% rename from src/legacy/core_plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts rename to src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts index 78685cd6becc8..f44603f4f19f4 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts +++ b/src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts @@ -18,7 +18,6 @@ */ import { writeFileSync, unlinkSync } from 'fs'; -import { Server } from 'hapi'; import { resolve } from 'path'; import { tmpdir } from 'os'; import { @@ -32,20 +31,6 @@ const mockUsageCollector = () => ({ makeUsageCollector: jest.fn().mockImplementationOnce((arg: object) => arg), }); -const serverWithConfig = (configPath: string): Server => { - return { - config: () => ({ - get: (key: string) => { - if (key !== 'telemetry.config' && key !== 'xpack.xpack_main.telemetry.config') { - throw new Error('Expected `telemetry.config`'); - } - - return configPath; - }, - }), - } as Server; -}; - describe('telemetry_usage_collector', () => { const tempDir = tmpdir(); const tempFiles = { @@ -129,11 +114,13 @@ describe('telemetry_usage_collector', () => { // note: it uses the file's path to get the directory, then looks for 'telemetry.yml' // exclusively, which is indirectly tested by passing it the wrong "file" in the same // dir - const server: Server = serverWithConfig(tempFiles.unreadable); // the `makeUsageCollector` is mocked above to return the argument passed to it const usageCollector = mockUsageCollector() as any; - const collectorOptions = createTelemetryUsageCollector(usageCollector, server); + const collectorOptions = createTelemetryUsageCollector( + usageCollector, + () => tempFiles.unreadable + ); expect(collectorOptions.type).toBe('static_telemetry'); expect(await collectorOptions.fetch({} as any)).toEqual(expectedObject); // Sending any as the callCluster client because it's not needed in this collector but TS requires it when calling it. diff --git a/src/legacy/core_plugins/telemetry/server/collectors/usage/telemetry_usage_collector.ts b/src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.ts similarity index 87% rename from src/legacy/core_plugins/telemetry/server/collectors/usage/telemetry_usage_collector.ts rename to src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.ts index 6919b6959aa8c..3daae90106e9e 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/usage/telemetry_usage_collector.ts +++ b/src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.ts @@ -18,13 +18,16 @@ */ import { accessSync, constants, readFileSync, statSync } from 'fs'; -import { Server } from 'hapi'; import { safeLoad } from 'js-yaml'; import { dirname, join } from 'path'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getConfigPath } from '../../../../../core/server/path'; + // look for telemetry.yml in the same places we expect kibana.yml import { ensureDeepObject } from './ensure_deep_object'; -import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; /** * The maximum file size before we ignore it (note: this limit is arbitrary). @@ -77,24 +80,20 @@ export async function readTelemetryFile(path: string): Promise<object | undefine export function createTelemetryUsageCollector( usageCollection: UsageCollectionSetup, - server: Server + getConfigPathFn = getConfigPath // exposed for testing ) { return usageCollection.makeUsageCollector({ type: 'static_telemetry', isReady: () => true, fetch: async () => { - const config = server.config(); - const configPath = config.get('telemetry.config') as string; + const configPath = getConfigPathFn(); const telemetryPath = join(dirname(configPath), 'telemetry.yml'); return await readTelemetryFile(telemetryPath); }, }); } -export function registerTelemetryUsageCollector( - usageCollection: UsageCollectionSetup, - server: Server -) { - const collector = createTelemetryUsageCollector(usageCollection, server); +export function registerTelemetryUsageCollector(usageCollection: UsageCollectionSetup) { + const collector = createTelemetryUsageCollector(usageCollection); usageCollection.registerCollector(collector); } diff --git a/src/plugins/telemetry/server/config.ts b/src/plugins/telemetry/server/config.ts new file mode 100644 index 0000000000000..7c62a37df7170 --- /dev/null +++ b/src/plugins/telemetry/server/config.ts @@ -0,0 +1,61 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { ENDPOINT_VERSION } from '../common/constants'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), + allowChangingOptInStatus: schema.boolean({ defaultValue: true }), + optIn: schema.conditional( + schema.siblingRef('allowChangingOptInStatus'), + schema.literal(false), + schema.maybe(schema.literal(true)), + schema.boolean({ defaultValue: true }), + { defaultValue: true } + ), + // `config` is used internally and not intended to be set + // config: Joi.string().default(getConfigPath()), TODO: Get it in some other way + banner: schema.boolean({ defaultValue: true }), + url: schema.conditional( + schema.contextRef('dev'), + schema.literal(true), + schema.string({ + defaultValue: `https://telemetry-staging.elastic.co/xpack/${ENDPOINT_VERSION}/send`, + }), + schema.string({ + defaultValue: `https://telemetry.elastic.co/xpack/${ENDPOINT_VERSION}/send`, + }) + ), + optInStatusUrl: schema.conditional( + schema.contextRef('dev'), + schema.literal(true), + schema.string({ + defaultValue: `https://telemetry-staging.elastic.co/opt_in_status/${ENDPOINT_VERSION}/send`, + }), + schema.string({ + defaultValue: `https://telemetry.elastic.co/opt_in_status/${ENDPOINT_VERSION}/send`, + }) + ), + sendUsageFrom: schema.oneOf([schema.literal('server'), schema.literal('browser')], { + defaultValue: 'browser', + }), +}); + +export type TelemetryConfigType = TypeOf<typeof configSchema>; diff --git a/src/legacy/core_plugins/telemetry/server/fetcher.ts b/src/plugins/telemetry/server/fetcher.ts similarity index 55% rename from src/legacy/core_plugins/telemetry/server/fetcher.ts rename to src/plugins/telemetry/server/fetcher.ts index d30ee10066813..be85824855ff3 100644 --- a/src/legacy/core_plugins/telemetry/server/fetcher.ts +++ b/src/plugins/telemetry/server/fetcher.ts @@ -18,48 +18,109 @@ */ import moment from 'moment'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; // @ts-ignore import fetch from 'node-fetch'; -import { telemetryCollectionManager } from './collection_manager'; +import { TelemetryCollectionManagerPluginStart } from 'src/plugins/telemetry_collection_manager/server'; +import { + PluginInitializerContext, + Logger, + SavedObjectsClientContract, + SavedObjectsClient, + CoreStart, + ICustomClusterClient, +} from '../../../core/server'; import { getTelemetryOptIn, getTelemetrySendUsageFrom, getTelemetryFailureDetails, -} from './telemetry_config'; +} from '../common/telemetry_config'; import { getTelemetrySavedObject, updateTelemetrySavedObject } from './telemetry_repository'; import { REPORT_INTERVAL_MS } from '../common/constants'; +import { TelemetryConfigType } from './config'; + +export interface FetcherTaskDepsStart { + telemetryCollectionManager: TelemetryCollectionManagerPluginStart; +} export class FetcherTask { private readonly initialCheckDelayMs = 60 * 1000 * 5; private readonly checkIntervalMs = 60 * 1000 * 60 * 12; + private readonly config$: Observable<TelemetryConfigType>; + private readonly currentKibanaVersion: string; + private readonly logger: Logger; private intervalId?: NodeJS.Timeout; private lastReported?: number; - private currentVersion: string; private isSending = false; - private server: any; + private internalRepository?: SavedObjectsClientContract; + private telemetryCollectionManager?: TelemetryCollectionManagerPluginStart; + private elasticsearchClient?: ICustomClusterClient; + + constructor(initializerContext: PluginInitializerContext<TelemetryConfigType>) { + this.config$ = initializerContext.config.create(); + this.currentKibanaVersion = initializerContext.env.packageInfo.version; + this.logger = initializerContext.logger.get('fetcher'); + } + + public start( + { savedObjects, elasticsearch }: CoreStart, + { telemetryCollectionManager }: FetcherTaskDepsStart + ) { + this.internalRepository = new SavedObjectsClient(savedObjects.createInternalRepository()); + this.telemetryCollectionManager = telemetryCollectionManager; + this.elasticsearchClient = elasticsearch.legacy.createClient('telemetry-fetcher'); + + setTimeout(() => { + this.sendIfDue(); + this.intervalId = setInterval(() => this.sendIfDue(), this.checkIntervalMs); + }, this.initialCheckDelayMs); + } + + public stop() { + if (this.intervalId) { + clearInterval(this.intervalId); + } + if (this.elasticsearchClient) { + this.elasticsearchClient.close(); + } + } - constructor(server: any) { - this.server = server; - this.currentVersion = this.server.config().get('pkg.version'); + private async sendIfDue() { + if (this.isSending) { + return; + } + const telemetryConfig = await this.getCurrentConfigs(); + if (!this.shouldSendReport(telemetryConfig)) { + return; + } + + try { + this.isSending = true; + const clusters = await this.fetchTelemetry(); + const { telemetryUrl } = telemetryConfig; + for (const cluster of clusters) { + await this.sendTelemetry(telemetryUrl, cluster); + } + + await this.updateLastReported(); + } catch (err) { + await this.updateReportFailure(telemetryConfig); + + this.logger.warn(`Error sending telemetry usage data: ${err}`); + } + this.isSending = false; } - private getInternalRepository = () => { - const { getSavedObjectsRepository } = this.server.savedObjects; - const { callWithInternalUser } = this.server.plugins.elasticsearch.getCluster('admin'); - const internalRepository = getSavedObjectsRepository(callWithInternalUser); - return internalRepository; - }; - - private getCurrentConfigs = async () => { - const internalRepository = this.getInternalRepository(); - const telemetrySavedObject = await getTelemetrySavedObject(internalRepository); - const config = this.server.config(); - const currentKibanaVersion = config.get('pkg.version'); - const configTelemetrySendUsageFrom = config.get('telemetry.sendUsageFrom'); - const allowChangingOptInStatus = config.get('telemetry.allowChangingOptInStatus'); - const configTelemetryOptIn = config.get('telemetry.optIn'); - const telemetryUrl = config.get('telemetry.url') as string; - const { failureCount, failureVersion } = await getTelemetryFailureDetails({ + private async getCurrentConfigs() { + const telemetrySavedObject = await getTelemetrySavedObject(this.internalRepository!); + const config = await this.config$.pipe(take(1)).toPromise(); + const currentKibanaVersion = this.currentKibanaVersion; + const configTelemetrySendUsageFrom = config.sendUsageFrom; + const allowChangingOptInStatus = config.allowChangingOptInStatus; + const configTelemetryOptIn = typeof config.optIn === 'undefined' ? null : config.optIn; + const telemetryUrl = config.url; + const { failureCount, failureVersion } = getTelemetryFailureDetails({ telemetrySavedObject, }); @@ -78,33 +139,30 @@ export class FetcherTask { failureCount, failureVersion, }; - }; + } - private updateLastReported = async () => { - const internalRepository = this.getInternalRepository(); + private async updateLastReported() { this.lastReported = Date.now(); - updateTelemetrySavedObject(internalRepository, { + updateTelemetrySavedObject(this.internalRepository!, { reportFailureCount: 0, lastReported: this.lastReported, }); - }; - - private updateReportFailure = async ({ failureCount }: { failureCount: number }) => { - const internalRepository = this.getInternalRepository(); + } - updateTelemetrySavedObject(internalRepository, { + private async updateReportFailure({ failureCount }: { failureCount: number }) { + updateTelemetrySavedObject(this.internalRepository!, { reportFailureCount: failureCount + 1, - reportFailureVersion: this.currentVersion, + reportFailureVersion: this.currentKibanaVersion, }); - }; + } - private shouldSendReport = ({ + private shouldSendReport({ telemetryOptIn, telemetrySendUsageFrom, reportFailureCount, currentVersion, reportFailureVersion, - }: any) => { + }: any) { if (reportFailureCount > 2 && reportFailureVersion === currentVersion) { return false; } @@ -115,21 +173,20 @@ export class FetcherTask { } } return false; - }; + } - private fetchTelemetry = async () => { - return await telemetryCollectionManager.getStats({ + private async fetchTelemetry() { + return await this.telemetryCollectionManager!.getStats({ unencrypted: false, - server: this.server, start: moment() .subtract(20, 'minutes') .toISOString(), end: moment().toISOString(), }); - }; + } - private sendTelemetry = async (url: string, cluster: any): Promise<void> => { - this.server.log(['debug', 'telemetry', 'fetcher'], `Sending usage stats.`); + private async sendTelemetry(url: string, cluster: any): Promise<void> { + this.logger.debug(`Sending usage stats.`); /** * send OPTIONS before sending usage data. * OPTIONS is less intrusive as it does not contain any payload and is used here to check if the endpoint is reachable. @@ -142,47 +199,5 @@ export class FetcherTask { method: 'post', body: cluster, }); - }; - - private sendIfDue = async () => { - if (this.isSending) { - return; - } - const telemetryConfig = await this.getCurrentConfigs(); - if (!this.shouldSendReport(telemetryConfig)) { - return; - } - - try { - this.isSending = true; - const clusters = await this.fetchTelemetry(); - const { telemetryUrl } = telemetryConfig; - for (const cluster of clusters) { - await this.sendTelemetry(telemetryUrl, cluster); - } - - await this.updateLastReported(); - } catch (err) { - await this.updateReportFailure(telemetryConfig); - - this.server.log( - ['warning', 'telemetry', 'fetcher'], - `Error sending telemetry usage data: ${err}` - ); - } - this.isSending = false; - }; - - public start = () => { - setTimeout(() => { - this.sendIfDue(); - this.intervalId = setInterval(() => this.sendIfDue(), this.checkIntervalMs); - }, this.initialCheckDelayMs); - }; - - public stop = () => { - if (this.intervalId) { - clearInterval(this.intervalId); - } - }; + } } diff --git a/src/legacy/core_plugins/telemetry/server/handle_old_settings/handle_old_settings.ts b/src/plugins/telemetry/server/handle_old_settings/handle_old_settings.ts similarity index 74% rename from src/legacy/core_plugins/telemetry/server/handle_old_settings/handle_old_settings.ts rename to src/plugins/telemetry/server/handle_old_settings/handle_old_settings.ts index b28a01bffa44d..3562ba452104c 100644 --- a/src/legacy/core_plugins/telemetry/server/handle_old_settings/handle_old_settings.ts +++ b/src/plugins/telemetry/server/handle_old_settings/handle_old_settings.ts @@ -26,27 +26,25 @@ * @return {Boolean} {@code true} if the banner should still be displayed. {@code false} if the banner should not be displayed. */ -import { Server } from 'hapi'; +import { IUiSettingsClient, SavedObjectsClientContract } from 'kibana/server'; import { CONFIG_TELEMETRY } from '../../common/constants'; import { updateTelemetrySavedObject } from '../telemetry_repository'; const CONFIG_ALLOW_REPORT = 'xPackMonitoring:allowReport'; -export async function handleOldSettings(server: Server) { - const { getSavedObjectsRepository } = server.savedObjects; - const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin'); - const savedObjectsClient = getSavedObjectsRepository(callWithInternalUser); - const uiSettings = server.uiSettingsServiceFactory({ savedObjectsClient }); - - const oldTelemetrySetting = await uiSettings.get(CONFIG_TELEMETRY); - const oldAllowReportSetting = await uiSettings.get(CONFIG_ALLOW_REPORT); +export async function handleOldSettings( + savedObjectsClient: SavedObjectsClientContract, + uiSettingsClient: IUiSettingsClient +) { + const oldTelemetrySetting = await uiSettingsClient.get(CONFIG_TELEMETRY); + const oldAllowReportSetting = await uiSettingsClient.get(CONFIG_ALLOW_REPORT); let legacyOptInValue = null; if (typeof oldTelemetrySetting === 'boolean') { legacyOptInValue = oldTelemetrySetting; } else if ( typeof oldAllowReportSetting === 'boolean' && - uiSettings.isOverridden(CONFIG_ALLOW_REPORT) + uiSettingsClient.isOverridden(CONFIG_ALLOW_REPORT) ) { legacyOptInValue = oldAllowReportSetting; } diff --git a/src/legacy/core_plugins/telemetry/server/handle_old_settings/index.ts b/src/plugins/telemetry/server/handle_old_settings/index.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/handle_old_settings/index.ts rename to src/plugins/telemetry/server/handle_old_settings/index.ts diff --git a/src/legacy/core_plugins/telemetry/server/index.ts b/src/plugins/telemetry/server/index.ts similarity index 60% rename from src/legacy/core_plugins/telemetry/server/index.ts rename to src/plugins/telemetry/server/index.ts index 85d7d80234ffc..d048c8f5e9427 100644 --- a/src/legacy/core_plugins/telemetry/server/index.ts +++ b/src/plugins/telemetry/server/index.ts @@ -17,15 +17,34 @@ * under the License. */ -import { PluginInitializerContext } from 'src/core/server'; +import { PluginInitializerContext, PluginConfigDescriptor } from 'kibana/server'; import { TelemetryPlugin } from './plugin'; import * as constants from '../common/constants'; +import { configSchema, TelemetryConfigType } from './config'; export { FetcherTask } from './fetcher'; -export { replaceTelemetryInjectedVars } from './telemetry_config'; export { handleOldSettings } from './handle_old_settings'; -export { telemetryCollectionManager } from './collection_manager'; -export { PluginsSetup } from './plugin'; -export const telemetryPlugin = (initializerContext: PluginInitializerContext) => +export { TelemetryPluginsSetup } from './plugin'; + +export const config: PluginConfigDescriptor<TelemetryConfigType> = { + schema: configSchema, + exposeToBrowser: { + enabled: true, + url: true, + banner: true, + allowChangingOptInStatus: true, + optIn: true, + optInStatusUrl: true, + sendUsageFrom: true, + }, +}; + +export const plugin = (initializerContext: PluginInitializerContext<TelemetryConfigType>) => new TelemetryPlugin(initializerContext); export { constants }; +export { + getClusterUuids, + getLocalLicense, + getLocalStats, + TelemetryLocalStats, +} from './telemetry_collection'; diff --git a/src/plugins/telemetry/server/plugin.ts b/src/plugins/telemetry/server/plugin.ts new file mode 100644 index 0000000000000..af512d234a7dc --- /dev/null +++ b/src/plugins/telemetry/server/plugin.ts @@ -0,0 +1,168 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable } from 'rxjs'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { + TelemetryCollectionManagerPluginSetup, + TelemetryCollectionManagerPluginStart, +} from 'src/plugins/telemetry_collection_manager/server'; +import { + CoreSetup, + PluginInitializerContext, + ISavedObjectsRepository, + CoreStart, + IUiSettingsClient, + SavedObjectsClient, + Plugin, + Logger, +} from '../../../core/server'; +import { registerRoutes } from './routes'; +import { registerCollection } from './telemetry_collection'; +import { + registerUiMetricUsageCollector, + registerTelemetryUsageCollector, + registerTelemetryPluginUsageCollector, + registerManagementUsageCollector, + registerApplicationUsageCollector, +} from './collectors'; +import { TelemetryConfigType } from './config'; +import { FetcherTask } from './fetcher'; +import { handleOldSettings } from './handle_old_settings'; + +export interface TelemetryPluginsSetup { + usageCollection: UsageCollectionSetup; + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup; +} + +export interface TelemetryPluginsStart { + telemetryCollectionManager: TelemetryCollectionManagerPluginStart; +} + +type SavedObjectsRegisterType = CoreSetup['savedObjects']['registerType']; + +export class TelemetryPlugin implements Plugin { + private readonly logger: Logger; + private readonly currentKibanaVersion: string; + private readonly config$: Observable<TelemetryConfigType>; + private readonly isDev: boolean; + private readonly fetcherTask: FetcherTask; + private savedObjectsClient?: ISavedObjectsRepository; + private uiSettingsClient?: IUiSettingsClient; + + constructor(initializerContext: PluginInitializerContext<TelemetryConfigType>) { + this.logger = initializerContext.logger.get(); + this.isDev = initializerContext.env.mode.dev; + this.currentKibanaVersion = initializerContext.env.packageInfo.version; + this.config$ = initializerContext.config.create(); + this.fetcherTask = new FetcherTask({ + ...initializerContext, + logger: this.logger, + }); + } + + public async setup( + core: CoreSetup, + { usageCollection, telemetryCollectionManager }: TelemetryPluginsSetup + ) { + const currentKibanaVersion = this.currentKibanaVersion; + const config$ = this.config$; + const isDev = this.isDev; + + registerCollection(telemetryCollectionManager, core.elasticsearch.dataClient); + const router = core.http.createRouter(); + + registerRoutes({ + config$, + currentKibanaVersion, + isDev, + router, + telemetryCollectionManager, + }); + + this.registerMappings(opts => core.savedObjects.registerType(opts)); + this.registerUsageCollectors(usageCollection, opts => core.savedObjects.registerType(opts)); + } + + public async start(core: CoreStart, { telemetryCollectionManager }: TelemetryPluginsStart) { + const { savedObjects, uiSettings } = core; + this.savedObjectsClient = savedObjects.createInternalRepository(); + const savedObjectsClient = new SavedObjectsClient(this.savedObjectsClient); + this.uiSettingsClient = uiSettings.asScopedToClient(savedObjectsClient); + + try { + await handleOldSettings(savedObjectsClient, this.uiSettingsClient); + } catch (error) { + this.logger.warn('Unable to update legacy telemetry configs.'); + } + + this.fetcherTask.start(core, { telemetryCollectionManager }); + } + + private registerMappings(registerType: SavedObjectsRegisterType) { + registerType({ + name: 'telemetry', + hidden: false, + namespaceAgnostic: true, + mappings: { + properties: { + enabled: { + type: 'boolean', + }, + sendUsageFrom: { + type: 'keyword', + }, + lastReported: { + type: 'date', + }, + lastVersionChecked: { + type: 'keyword', + }, + userHasSeenNotice: { + type: 'boolean', + }, + reportFailureCount: { + type: 'integer', + }, + reportFailureVersion: { + type: 'keyword', + }, + }, + }, + }); + } + + private registerUsageCollectors( + usageCollection: UsageCollectionSetup, + registerType: SavedObjectsRegisterType + ) { + const getSavedObjectsClient = () => this.savedObjectsClient; + const getUiSettingsClient = () => this.uiSettingsClient; + + registerTelemetryPluginUsageCollector(usageCollection, { + currentKibanaVersion: this.currentKibanaVersion, + config$: this.config$, + getSavedObjectsClient, + }); + registerTelemetryUsageCollector(usageCollection); + registerManagementUsageCollector(usageCollection, getUiSettingsClient); + registerUiMetricUsageCollector(usageCollection, registerType, getSavedObjectsClient); + registerApplicationUsageCollector(usageCollection, registerType, getSavedObjectsClient); + } +} diff --git a/src/legacy/core_plugins/telemetry/server/routes/index.ts b/src/plugins/telemetry/server/routes/index.ts similarity index 61% rename from src/legacy/core_plugins/telemetry/server/routes/index.ts rename to src/plugins/telemetry/server/routes/index.ts index 31ff1682d6806..ad84cb9d2665d 100644 --- a/src/legacy/core_plugins/telemetry/server/routes/index.ts +++ b/src/plugins/telemetry/server/routes/index.ts @@ -17,22 +17,27 @@ * under the License. */ -import { Legacy } from 'kibana'; -import { CoreSetup } from 'src/core/server'; +import { Observable } from 'rxjs'; +import { IRouter } from 'kibana/server'; +import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; import { registerTelemetryOptInRoutes } from './telemetry_opt_in'; import { registerTelemetryUsageStatsRoutes } from './telemetry_usage_stats'; import { registerTelemetryOptInStatsRoutes } from './telemetry_opt_in_stats'; import { registerTelemetryUserHasSeenNotice } from './telemetry_user_has_seen_notice'; +import { TelemetryConfigType } from '../config'; interface RegisterRoutesParams { - core: CoreSetup; + isDev: boolean; + config$: Observable<TelemetryConfigType>; currentKibanaVersion: string; - server: Legacy.Server; + router: IRouter; + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup; } -export function registerRoutes({ core, currentKibanaVersion, server }: RegisterRoutesParams) { - registerTelemetryOptInRoutes({ core, currentKibanaVersion, server }); - registerTelemetryUsageStatsRoutes(server); - registerTelemetryOptInStatsRoutes(server); - registerTelemetryUserHasSeenNotice(server); +export function registerRoutes(options: RegisterRoutesParams) { + const { isDev, telemetryCollectionManager, router } = options; + registerTelemetryOptInRoutes(options); + registerTelemetryUsageStatsRoutes(router, telemetryCollectionManager, isDev); + registerTelemetryOptInStatsRoutes(router, telemetryCollectionManager); + registerTelemetryUserHasSeenNotice(router); } diff --git a/src/plugins/telemetry/server/routes/telemetry_opt_in.ts b/src/plugins/telemetry/server/routes/telemetry_opt_in.ts new file mode 100644 index 0000000000000..e65ade0ab8aaa --- /dev/null +++ b/src/plugins/telemetry/server/routes/telemetry_opt_in.ts @@ -0,0 +1,101 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import moment from 'moment'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'kibana/server'; +import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; +import { getTelemetryAllowChangingOptInStatus } from '../../common/telemetry_config'; +import { sendTelemetryOptInStatus } from './telemetry_opt_in_stats'; + +import { + TelemetrySavedObjectAttributes, + updateTelemetrySavedObject, + getTelemetrySavedObject, +} from '../telemetry_repository'; +import { TelemetryConfigType } from '../config'; + +interface RegisterOptInRoutesParams { + currentKibanaVersion: string; + router: IRouter; + config$: Observable<TelemetryConfigType>; + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup; +} + +export function registerTelemetryOptInRoutes({ + config$, + router, + currentKibanaVersion, + telemetryCollectionManager, +}: RegisterOptInRoutesParams) { + router.post( + { + path: '/api/telemetry/v2/optIn', + validate: { + body: schema.object({ enabled: schema.boolean() }), + }, + }, + async (context, req, res) => { + const newOptInStatus = req.body.enabled; + const attributes: TelemetrySavedObjectAttributes = { + enabled: newOptInStatus, + lastVersionChecked: currentKibanaVersion, + }; + const config = await config$.pipe(take(1)).toPromise(); + const telemetrySavedObject = await getTelemetrySavedObject(context.core.savedObjects.client); + + if (telemetrySavedObject === false) { + // If we get false, we couldn't get the saved object due to lack of permissions + // so we can assume the user won't be able to update it either + return res.forbidden(); + } + + const configTelemetryAllowChangingOptInStatus = config.allowChangingOptInStatus; + const allowChangingOptInStatus = getTelemetryAllowChangingOptInStatus({ + telemetrySavedObject, + configTelemetryAllowChangingOptInStatus, + }); + if (!allowChangingOptInStatus) { + return res.badRequest({ + body: JSON.stringify({ error: 'Not allowed to change Opt-in Status.' }), + }); + } + + if (config.sendUsageFrom === 'server') { + const optInStatusUrl = config.optInStatusUrl; + await sendTelemetryOptInStatus( + telemetryCollectionManager, + { optInStatusUrl, newOptInStatus }, + { + start: moment() + .subtract(20, 'minutes') + .toISOString(), + end: moment().toISOString(), + unencrypted: false, + } + ); + } + + await updateTelemetrySavedObject(context.core.savedObjects.client, attributes); + return res.ok({}); + } + ); +} diff --git a/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in_stats.ts b/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts similarity index 65% rename from src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in_stats.ts rename to src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts index e64f3f6ff8a94..3263c6d49523c 100644 --- a/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in_stats.ts +++ b/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts @@ -19,10 +19,14 @@ // @ts-ignore import fetch from 'node-fetch'; -import Joi from 'joi'; import moment from 'moment'; -import { Legacy } from 'kibana'; -import { telemetryCollectionManager, StatsGetterConfig } from '../collection_manager'; + +import { IRouter } from 'kibana/server'; +import { schema } from '@kbn/config-schema'; +import { + TelemetryCollectionManagerPluginSetup, + StatsGetterConfig, +} from 'src/plugins/telemetry_collection_manager/server'; interface SendTelemetryOptInStatusConfig { optInStatusUrl: string; @@ -30,6 +34,7 @@ interface SendTelemetryOptInStatusConfig { } export async function sendTelemetryOptInStatus( + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup, config: SendTelemetryOptInStatusConfig, statsGetterConfig: StatsGetterConfig ) { @@ -45,41 +50,42 @@ export async function sendTelemetryOptInStatus( }); } -export function registerTelemetryOptInStatsRoutes(server: Legacy.Server) { - server.route({ - method: 'POST', - path: '/api/telemetry/v2/clusters/_opt_in_stats', - options: { +export function registerTelemetryOptInStatsRoutes( + router: IRouter, + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup +) { + router.post( + { + path: '/api/telemetry/v2/clusters/_opt_in_stats', validate: { - payload: Joi.object({ - enabled: Joi.bool().required(), - unencrypted: Joi.bool().default(true), + body: schema.object({ + enabled: schema.boolean(), + unencrypted: schema.boolean({ defaultValue: true }), }), }, }, - handler: async (req: any, h: any) => { + async (context, req, res) => { try { - const newOptInStatus = req.payload.enabled; - const unencrypted = req.payload.unencrypted; - const statsGetterConfig = { + const newOptInStatus = req.body.enabled; + const unencrypted = req.body.unencrypted; + + const statsGetterConfig: StatsGetterConfig = { start: moment() .subtract(20, 'minutes') .toISOString(), end: moment().toISOString(), - server: req.server, - req, unencrypted, + request: req, }; const optInStatus = await telemetryCollectionManager.getOptInStats( newOptInStatus, statsGetterConfig ); - - return h.response(optInStatus).code(200); + return res.ok({ body: optInStatus }); } catch (err) { - return h.response([]).code(200); + return res.ok({ body: [] }); } - }, - }); + } + ); } diff --git a/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts b/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts new file mode 100644 index 0000000000000..15d4c0ca2fa55 --- /dev/null +++ b/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts @@ -0,0 +1,82 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import moment from 'moment'; +import { schema } from '@kbn/config-schema'; +import { TypeOptions } from '@kbn/config-schema/target/types/types'; +import { IRouter } from 'kibana/server'; +import { + TelemetryCollectionManagerPluginSetup, + StatsGetterConfig, +} from 'src/plugins/telemetry_collection_manager/server'; + +const validate: TypeOptions<string | number>['validate'] = value => { + if (!moment(value).isValid()) { + return `${value} is not a valid date`; + } +}; + +const dateSchema = schema.oneOf([schema.string({ validate }), schema.number({ validate })]); + +export function registerTelemetryUsageStatsRoutes( + router: IRouter, + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup, + isDev: boolean +) { + router.post( + { + path: '/api/telemetry/v2/clusters/_stats', + validate: { + body: schema.object({ + unencrypted: schema.boolean({ defaultValue: false }), + timeRange: schema.object({ + min: dateSchema, + max: dateSchema, + }), + }), + }, + }, + async (context, req, res) => { + const start = moment(req.body.timeRange.min).toISOString(); + const end = moment(req.body.timeRange.max).toISOString(); + const unencrypted = req.body.unencrypted; + + try { + const statsConfig: StatsGetterConfig = { + unencrypted, + start, + end, + request: req, + }; + const stats = await telemetryCollectionManager.getStats(statsConfig); + return res.ok({ body: stats }); + } catch (err) { + if (isDev) { + // don't ignore errors when running in dev mode + throw err; + } + if (unencrypted && err.status === 403) { + return res.forbidden(); + } + // ignore errors and return empty set + return res.ok({ body: [] }); + } + } + ); +} diff --git a/src/legacy/core_plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts b/src/plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts similarity index 65% rename from src/legacy/core_plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts rename to src/plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts index 665e6d9aaeb75..45a5147107f02 100644 --- a/src/legacy/core_plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts +++ b/src/plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts @@ -17,8 +17,7 @@ * under the License. */ -import { Legacy } from 'kibana'; -import { Request } from 'hapi'; +import { IRouter } from 'kibana/server'; import { TelemetrySavedObject, TelemetrySavedObjectAttributes, @@ -26,19 +25,14 @@ import { updateTelemetrySavedObject, } from '../telemetry_repository'; -const getInternalRepository = (server: Legacy.Server) => { - const { getSavedObjectsRepository } = server.savedObjects; - const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin'); - const internalRepository = getSavedObjectsRepository(callWithInternalUser); - return internalRepository; -}; - -export function registerTelemetryUserHasSeenNotice(server: Legacy.Server) { - server.route({ - method: 'PUT', - path: '/api/telemetry/v2/userHasSeenNotice', - handler: async (req: Request): Promise<TelemetrySavedObjectAttributes> => { - const internalRepository = getInternalRepository(server); +export function registerTelemetryUserHasSeenNotice(router: IRouter) { + router.put( + { + path: '/api/telemetry/v2/userHasSeenNotice', + validate: false, + }, + async (context, req, res) => { + const internalRepository = context.core.savedObjects.client; const telemetrySavedObject: TelemetrySavedObject = await getTelemetrySavedObject( internalRepository ); @@ -50,7 +44,7 @@ export function registerTelemetryUserHasSeenNotice(server: Legacy.Server) { }; await updateTelemetrySavedObject(internalRepository, updatedAttributes); - return updatedAttributes; - }, - }); + return res.ok({ body: updatedAttributes }); + } + ); } diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/__tests__/get_cluster_info.js b/src/plugins/telemetry/server/telemetry_collection/__tests__/get_cluster_info.js similarity index 100% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/__tests__/get_cluster_info.js rename to src/plugins/telemetry/server/telemetry_collection/__tests__/get_cluster_info.js diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/__tests__/get_cluster_stats.js b/src/plugins/telemetry/server/telemetry_collection/__tests__/get_cluster_stats.js similarity index 100% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/__tests__/get_cluster_stats.js rename to src/plugins/telemetry/server/telemetry_collection/__tests__/get_cluster_stats.js diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/__tests__/get_local_stats.js b/src/plugins/telemetry/server/telemetry_collection/__tests__/get_local_stats.js similarity index 96% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/__tests__/get_local_stats.js rename to src/plugins/telemetry/server/telemetry_collection/__tests__/get_local_stats.js index 94627953f1ac6..ac3c5307adcf6 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_collection/__tests__/get_local_stats.js +++ b/src/plugins/telemetry/server/telemetry_collection/__tests__/get_local_stats.js @@ -128,9 +128,14 @@ describe('get_local_stats', () => { }, }; + const context = { + logger: console, + version: '8.0.0', + }; + describe('handleLocalStats', () => { it('returns expected object without xpack and kibana data', () => { - const result = handleLocalStats(getMockServer(), clusterInfo, clusterStats); + const result = handleLocalStats(clusterInfo, clusterStats, void 0, context); expect(result.cluster_uuid).to.eql(combinedStatsResult.cluster_uuid); expect(result.cluster_name).to.eql(combinedStatsResult.cluster_name); expect(result.cluster_stats).to.eql(combinedStatsResult.cluster_stats); @@ -141,7 +146,7 @@ describe('get_local_stats', () => { }); it('returns expected object with xpack', () => { - const result = handleLocalStats(getMockServer(), clusterInfo, clusterStats); + const result = handleLocalStats(clusterInfo, clusterStats, void 0, context); const { stack_stats: stack, ...cluster } = result; expect(cluster.collection).to.be(combinedStatsResult.collection); expect(cluster.cluster_uuid).to.be(combinedStatsResult.cluster_uuid); diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/constants.ts b/src/plugins/telemetry/server/telemetry_collection/constants.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/constants.ts rename to src/plugins/telemetry/server/telemetry_collection/constants.ts diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_cluster_info.ts b/src/plugins/telemetry/server/telemetry_collection/get_cluster_info.ts similarity index 92% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/get_cluster_info.ts rename to src/plugins/telemetry/server/telemetry_collection/get_cluster_info.ts index 67812457ed4ec..d5f0d2d8c9598 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_cluster_info.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_cluster_info.ts @@ -17,7 +17,7 @@ * under the License. */ -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { APICaller } from 'kibana/server'; // This can be removed when the ES client improves the types export interface ESClusterInfo { @@ -43,6 +43,6 @@ export interface ESClusterInfo { * * @param {function} callCluster The callWithInternalUser handler (exposed for testing) */ -export function getClusterInfo(callCluster: CallCluster) { +export function getClusterInfo(callCluster: APICaller) { return callCluster<ESClusterInfo>('info'); } diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts b/src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts similarity index 86% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts rename to src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts index 4abd95f0cf66d..89e2cae777985 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts @@ -17,15 +17,15 @@ * under the License. */ -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { ClusterDetailsGetter } from 'src/plugins/telemetry_collection_manager/server'; +import { APICaller } from 'kibana/server'; import { TIMEOUT } from './constants'; -import { ClusterDetailsGetter } from '../collection_manager'; /** * Get the cluster stats from the connected cluster. * * This is the equivalent to GET /_cluster/stats?timeout=30s. */ -export async function getClusterStats(callCluster: CallCluster) { +export async function getClusterStats(callCluster: APICaller) { return await callCluster('cluster.stats', { timeout: TIMEOUT, }); diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_kibana.ts b/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts similarity index 79% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/get_kibana.ts rename to src/plugins/telemetry/server/telemetry_collection/get_kibana.ts index 537d5a85911cd..86c6731e11d37 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_kibana.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts @@ -19,7 +19,8 @@ import { omit } from 'lodash'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { APICaller } from 'kibana/server'; +import { StatsCollectionContext } from 'src/plugins/telemetry_collection_manager/server'; export interface KibanaUsageStats { kibana: { @@ -37,12 +38,12 @@ export interface KibanaUsageStats { [plugin: string]: any; } -export function handleKibanaStats(server: any, response?: KibanaUsageStats) { +export function handleKibanaStats( + { logger, version: serverVersion }: StatsCollectionContext, + response?: KibanaUsageStats +) { if (!response) { - server.log( - ['warning', 'telemetry', 'local-stats'], - 'No Kibana stats returned from usage collectors' - ); + logger.warn('No Kibana stats returned from usage collectors'); return; } @@ -60,10 +61,7 @@ export function handleKibanaStats(server: any, response?: KibanaUsageStats) { }; }, {}); - const version = server - .config() - .get('pkg.version') - .replace(/-snapshot/i, ''); + const version = serverVersion.replace(/-snapshot/i, ''); // Shouldn't we better maintain the -snapshot so we can differentiate between actual final releases and snapshots? // combine core stats (os types, saved objects) with plugin usage stats // organize the object into the same format as monitoring-enabled telemetry @@ -79,7 +77,7 @@ export function handleKibanaStats(server: any, response?: KibanaUsageStats) { export async function getKibana( usageCollection: UsageCollectionSetup, - callWithInternalUser: CallCluster + callWithInternalUser: APICaller ): Promise<KibanaUsageStats> { const usage = await usageCollection.bulkFetch(callWithInternalUser); return usageCollection.toObject(usage); diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_local_license.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_license.ts similarity index 80% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/get_local_license.ts rename to src/plugins/telemetry/server/telemetry_collection/get_local_license.ts index 589392ffb6095..ad0666c7ad153 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_local_license.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_license.ts @@ -17,26 +17,12 @@ * under the License. */ -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; -import { LicenseGetter } from '../collection_manager'; +import { APICaller } from 'kibana/server'; +import { ESLicense, LicenseGetter } from 'src/plugins/telemetry_collection_manager/server'; -// From https://www.elastic.co/guide/en/elasticsearch/reference/current/get-license.html -export interface ESLicense { - status: string; - uid: string; - type: string; - issue_date: string; - issue_date_in_millis: number; - expiry_date: string; - expirty_date_in_millis: number; - max_nodes: number; - issued_to: string; - issuer: string; - start_date_in_millis: number; -} let cachedLicense: ESLicense | undefined; -function fetchLicense(callCluster: CallCluster, local: boolean) { +function fetchLicense(callCluster: APICaller, local: boolean) { return callCluster<{ license: ESLicense }>('transport.request', { method: 'GET', path: '/_license', @@ -55,7 +41,7 @@ function fetchLicense(callCluster: CallCluster, local: boolean) { * * Like any X-Pack related API, X-Pack must installed for this to work. */ -async function getLicenseFromLocalOrMaster(callCluster: CallCluster) { +async function getLicenseFromLocalOrMaster(callCluster: APICaller) { // Fetching the local license is cheaper than getting it from the master and good enough const { license } = await fetchLicense(callCluster, true).catch(async err => { if (cachedLicense) { diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_local_stats.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts similarity index 82% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/get_local_stats.ts rename to src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts index d99710deb1cbc..19d5c2970361c 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_local_stats.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts @@ -17,10 +17,13 @@ * under the License. */ +import { + StatsGetter, + StatsCollectionContext, +} from 'src/plugins/telemetry_collection_manager/server'; import { getClusterInfo, ESClusterInfo } from './get_cluster_info'; import { getClusterStats } from './get_cluster_stats'; import { getKibana, handleKibanaStats, KibanaUsageStats } from './get_kibana'; -import { StatsGetter } from '../collection_manager'; /** * Handle the separate local calls by combining them into a single object response that looks like the @@ -32,10 +35,10 @@ import { StatsGetter } from '../collection_manager'; * @param {Object} kibana The Kibana Usage stats */ export function handleLocalStats( - server: any, { cluster_name, cluster_uuid, version }: ESClusterInfo, { _nodes, cluster_name: clusterName, ...clusterStats }: any, - kibana: KibanaUsageStats + kibana: KibanaUsageStats, + context: StatsCollectionContext ) { return { timestamp: new Date().toISOString(), @@ -45,7 +48,7 @@ export function handleLocalStats( cluster_stats: clusterStats, collection: 'local', stack_stats: { - kibana: handleKibanaStats(server, kibana), + kibana: handleKibanaStats(context, kibana), }, }; } @@ -55,8 +58,12 @@ export type TelemetryLocalStats = ReturnType<typeof handleLocalStats>; /** * Get statistics for all products joined by Elasticsearch cluster. */ -export const getLocalStats: StatsGetter<TelemetryLocalStats> = async (clustersDetails, config) => { - const { server, callCluster, usageCollection } = config; +export const getLocalStats: StatsGetter<{}, TelemetryLocalStats> = async ( + clustersDetails, + config, + context +) => { + const { callCluster, usageCollection } = config; return await Promise.all( clustersDetails.map(async clustersDetail => { @@ -65,7 +72,7 @@ export const getLocalStats: StatsGetter<TelemetryLocalStats> = async (clustersDe getClusterStats(callCluster), // cluster stats (not to be confused with cluster _state_) getKibana(usageCollection, callCluster), ]); - return handleLocalStats(server, clusterInfo, clusterStats, kibana); + return handleLocalStats(clusterInfo, clusterStats, kibana, context); }) ); }; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/index.ts b/src/plugins/telemetry/server/telemetry_collection/index.ts similarity index 87% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/index.ts rename to src/plugins/telemetry/server/telemetry_collection/index.ts index 9ac94216c21bc..377ddab7b877c 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_collection/index.ts +++ b/src/plugins/telemetry/server/telemetry_collection/index.ts @@ -17,6 +17,7 @@ * under the License. */ -export { getLocalStats } from './get_local_stats'; +export { getLocalStats, TelemetryLocalStats } from './get_local_stats'; +export { getLocalLicense } from './get_local_license'; export { getClusterUuids } from './get_cluster_stats'; export { registerCollection } from './register_collection'; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/register_collection.ts b/src/plugins/telemetry/server/telemetry_collection/register_collection.ts similarity index 86% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/register_collection.ts rename to src/plugins/telemetry/server/telemetry_collection/register_collection.ts index 6580b47dba08e..833fd9f7fd5bc 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_collection/register_collection.ts +++ b/src/plugins/telemetry/server/telemetry_collection/register_collection.ts @@ -36,14 +36,18 @@ * under the License. */ -import { telemetryCollectionManager } from '../collection_manager'; +import { IClusterClient } from 'kibana/server'; +import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; import { getLocalStats } from './get_local_stats'; import { getClusterUuids } from './get_cluster_stats'; import { getLocalLicense } from './get_local_license'; -export function registerCollection() { +export function registerCollection( + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup, + esCluster: IClusterClient +) { telemetryCollectionManager.setCollection({ - esCluster: 'data', + esCluster, title: 'local', priority: 0, statsGetter: getLocalStats, diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts b/src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts similarity index 95% rename from src/legacy/core_plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts rename to src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts index 7cc177878de4d..ebb583e88d4e1 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts +++ b/src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts @@ -18,7 +18,7 @@ */ import { getTelemetrySavedObject } from './get_telemetry_saved_object'; -import { SavedObjectsErrorHelpers } from '../../../../../core/server'; +import { SavedObjectsErrorHelpers } from '../../../../core/server'; describe('getTelemetrySavedObject', () => { it('returns null when saved object not found', async () => { @@ -79,7 +79,7 @@ function getCallGetTelemetrySavedObjectParams( async function callGetTelemetrySavedObject(params: CallGetTelemetrySavedObjectParams) { const savedObjectsClient = getMockSavedObjectsClient(params); - return await getTelemetrySavedObject(savedObjectsClient); + return await getTelemetrySavedObject(savedObjectsClient as any); } const SavedObjectForbiddenMessage = 'savedObjectForbidden'; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.ts b/src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.ts similarity index 81% rename from src/legacy/core_plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.ts rename to src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.ts index 91965ef201ecb..1b3ea093fb3d8 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.ts +++ b/src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.ts @@ -17,13 +17,16 @@ * under the License. */ -import { TelemetrySavedObjectAttributes } from './'; -import { SavedObjectsErrorHelpers } from '../../../../../core/server'; +import { SavedObjectsErrorHelpers, SavedObjectsClientContract } from '../../../../core/server'; +import { TelemetrySavedObject } from './'; -export type TelemetrySavedObject = TelemetrySavedObjectAttributes | null | false; -type GetTelemetrySavedObject = (repository: any) => Promise<TelemetrySavedObject>; +type GetTelemetrySavedObject = ( + repository: SavedObjectsClientContract +) => Promise<TelemetrySavedObject>; -export const getTelemetrySavedObject: GetTelemetrySavedObject = async (repository: any) => { +export const getTelemetrySavedObject: GetTelemetrySavedObject = async ( + repository: SavedObjectsClientContract +) => { try { const { attributes } = await repository.get('telemetry', 'telemetry'); return attributes; diff --git a/src/plugins/telemetry/server/telemetry_repository/index.ts b/src/plugins/telemetry/server/telemetry_repository/index.ts new file mode 100644 index 0000000000000..b98fa6971e481 --- /dev/null +++ b/src/plugins/telemetry/server/telemetry_repository/index.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { getTelemetrySavedObject } from './get_telemetry_saved_object'; +export { updateTelemetrySavedObject } from './update_telemetry_saved_object'; +export { + TelemetrySavedObject, + TelemetrySavedObjectAttributes, +} from '../../common/telemetry_config/types'; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_repository/update_telemetry_saved_object.ts b/src/plugins/telemetry/server/telemetry_repository/update_telemetry_saved_object.ts similarity index 89% rename from src/legacy/core_plugins/telemetry/server/telemetry_repository/update_telemetry_saved_object.ts rename to src/plugins/telemetry/server/telemetry_repository/update_telemetry_saved_object.ts index b66e01faaa6bc..64a2f675e4fd8 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_repository/update_telemetry_saved_object.ts +++ b/src/plugins/telemetry/server/telemetry_repository/update_telemetry_saved_object.ts @@ -17,11 +17,11 @@ * under the License. */ +import { SavedObjectsErrorHelpers, SavedObjectsClientContract } from '../../../../core/server'; import { TelemetrySavedObjectAttributes } from './'; -import { SavedObjectsErrorHelpers } from '../../../../../core/server'; export async function updateTelemetrySavedObject( - savedObjectsClient: any, + savedObjectsClient: SavedObjectsClientContract, savedObjectAttributes: TelemetrySavedObjectAttributes ) { try { diff --git a/src/plugins/telemetry_collection_manager/README.md b/src/plugins/telemetry_collection_manager/README.md new file mode 100644 index 0000000000000..3ded16e08a7aa --- /dev/null +++ b/src/plugins/telemetry_collection_manager/README.md @@ -0,0 +1,7 @@ +# Telemetry Collection Manager + +Telemetry's collection manager to go through all the telemetry sources when fetching it before reporting. + +It has been split into a separate plugin because the `telemetry` plugin was pretty much being a passthrough in many cases to instantiate and maintain the logic of this bit. + +For separation of concerns, it's better to have this piece of logic independent to the rest. diff --git a/src/legacy/core_plugins/telemetry/public/views/management/index.ts b/src/plugins/telemetry_collection_manager/common/index.ts similarity index 87% rename from src/legacy/core_plugins/telemetry/public/views/management/index.ts rename to src/plugins/telemetry_collection_manager/common/index.ts index 2e9f064ec80d8..5ad29c06bb682 100644 --- a/src/legacy/core_plugins/telemetry/public/views/management/index.ts +++ b/src/plugins/telemetry_collection_manager/common/index.ts @@ -17,4 +17,5 @@ * under the License. */ -import './management'; +export const PLUGIN_ID = 'telemetryCollectionManager'; +export const PLUGIN_NAME = 'telemetry_collection_manager'; diff --git a/src/plugins/telemetry_collection_manager/kibana.json b/src/plugins/telemetry_collection_manager/kibana.json new file mode 100644 index 0000000000000..f4278265834a4 --- /dev/null +++ b/src/plugins/telemetry_collection_manager/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "telemetryCollectionManager", + "version": "kibana", + "server": true, + "ui": false, + "requiredPlugins": [ + "usageCollection" + ], + "optionalPlugins": [] +} diff --git a/src/legacy/core_plugins/telemetry/server/collectors/encryption/encrypt.test.ts b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.test.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/encryption/encrypt.test.ts rename to src/plugins/telemetry_collection_manager/server/encryption/encrypt.test.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/encryption/encrypt.ts b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/encryption/encrypt.ts rename to src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/encryption/index.ts b/src/plugins/telemetry_collection_manager/server/encryption/index.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/encryption/index.ts rename to src/plugins/telemetry_collection_manager/server/encryption/index.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/encryption/telemetry_jwks.ts b/src/plugins/telemetry_collection_manager/server/encryption/telemetry_jwks.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/encryption/telemetry_jwks.ts rename to src/plugins/telemetry_collection_manager/server/encryption/telemetry_jwks.ts diff --git a/src/plugins/telemetry_collection_manager/server/index.ts b/src/plugins/telemetry_collection_manager/server/index.ts new file mode 100644 index 0000000000000..8761c28e14095 --- /dev/null +++ b/src/plugins/telemetry_collection_manager/server/index.ts @@ -0,0 +1,41 @@ +/* + * 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 { PluginInitializerContext } from 'kibana/server'; +import { TelemetryCollectionManagerPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. + +export function plugin(initializerContext: PluginInitializerContext) { + return new TelemetryCollectionManagerPlugin(initializerContext); +} + +export { + TelemetryCollectionManagerPluginSetup, + TelemetryCollectionManagerPluginStart, + ESLicense, + StatsCollectionConfig, + StatsGetter, + StatsGetterConfig, + StatsCollectionContext, + ClusterDetails, + ClusterDetailsGetter, + LicenseGetter, +} from './types'; diff --git a/src/plugins/telemetry_collection_manager/server/plugin.ts b/src/plugins/telemetry_collection_manager/server/plugin.ts new file mode 100644 index 0000000000000..7e8dff9e0aec1 --- /dev/null +++ b/src/plugins/telemetry_collection_manager/server/plugin.ts @@ -0,0 +1,253 @@ +/* + * 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 { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, +} from '../../../core/server'; + +import { + TelemetryCollectionManagerPluginSetup, + TelemetryCollectionManagerPluginStart, + BasicStatsPayload, + CollectionConfig, + Collection, + StatsGetterConfig, + StatsCollectionConfig, + UsageStatsPayload, + StatsCollectionContext, +} from './types'; + +import { encryptTelemetry } from './encryption'; + +interface TelemetryCollectionPluginsDepsSetup { + usageCollection: UsageCollectionSetup; +} + +export class TelemetryCollectionManagerPlugin + implements Plugin<TelemetryCollectionManagerPluginSetup, TelemetryCollectionManagerPluginStart> { + private readonly logger: Logger; + private readonly collections: Array<Collection<any>> = []; + private usageGetterMethodPriority = -1; + private usageCollection?: UsageCollectionSetup; + private readonly isDev: boolean; + private readonly version: string; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + this.isDev = initializerContext.env.mode.dev; + this.version = initializerContext.env.packageInfo.version; + } + + public setup(core: CoreSetup, { usageCollection }: TelemetryCollectionPluginsDepsSetup) { + this.usageCollection = usageCollection; + + return { + setCollection: this.setCollection.bind(this), + getOptInStats: this.getOptInStats.bind(this), + getStats: this.getStats.bind(this), + }; + } + + public start(core: CoreStart) { + return { + setCollection: this.setCollection.bind(this), + getOptInStats: this.getOptInStats.bind(this), + getStats: this.getStats.bind(this), + }; + } + + public stop() {} + + private setCollection<CustomContext extends Record<string, any>, T extends BasicStatsPayload>( + collectionConfig: CollectionConfig<CustomContext, T> + ) { + const { + title, + priority, + esCluster, + statsGetter, + clusterDetailsGetter, + licenseGetter, + } = collectionConfig; + + if (typeof priority !== 'number') { + throw new Error('priority must be set.'); + } + if (priority === this.usageGetterMethodPriority) { + throw new Error(`A Usage Getter with the same priority is already set.`); + } + + if (priority > this.usageGetterMethodPriority) { + if (!statsGetter) { + throw Error('Stats getter method not set.'); + } + if (!esCluster) { + throw Error('esCluster name must be set for the getCluster method.'); + } + if (!clusterDetailsGetter) { + throw Error('Cluster UUIds method is not set.'); + } + if (!licenseGetter) { + throw Error('License getter method not set.'); + } + + this.collections.unshift({ + licenseGetter, + statsGetter, + clusterDetailsGetter, + esCluster, + title, + }); + this.usageGetterMethodPriority = priority; + } + } + + private getStatsCollectionConfig( + config: StatsGetterConfig, + collection: Collection, + usageCollection: UsageCollectionSetup + ): StatsCollectionConfig { + const { start, end, request } = config; + + const callCluster = config.unencrypted + ? collection.esCluster.asScoped(request).callAsCurrentUser + : collection.esCluster.callAsInternalUser; + + return { callCluster, start, end, usageCollection }; + } + + private async getOptInStats(optInStatus: boolean, config: StatsGetterConfig) { + if (!this.usageCollection) { + return []; + } + for (const collection of this.collections) { + const statsCollectionConfig = this.getStatsCollectionConfig( + config, + collection, + this.usageCollection + ); + try { + const optInStats = await this.getOptInStatsForCollection( + collection, + optInStatus, + statsCollectionConfig + ); + if (optInStats && optInStats.length) { + this.logger.debug(`Got Opt In stats using ${collection.title} collection.`); + if (config.unencrypted) { + return optInStats; + } + return encryptTelemetry(optInStats, this.isDev); + } + } catch (err) { + this.logger.debug(`Failed to collect any opt in stats with registered collections.`); + // swallow error to try next collection; + } + } + + return []; + } + + private getOptInStatsForCollection = async ( + collection: Collection, + optInStatus: boolean, + statsCollectionConfig: StatsCollectionConfig + ) => { + const context: StatsCollectionContext = { + logger: this.logger.get(collection.title), + isDev: this.isDev, + version: this.version, + ...collection.customContext, + }; + + const clustersDetails = await collection.clusterDetailsGetter(statsCollectionConfig, context); + return clustersDetails.map(({ clusterUuid }) => ({ + cluster_uuid: clusterUuid, + opt_in_status: optInStatus, + })); + }; + + private async getStats(config: StatsGetterConfig) { + if (!this.usageCollection) { + return []; + } + for (const collection of this.collections) { + const statsCollectionConfig = this.getStatsCollectionConfig( + config, + collection, + this.usageCollection + ); + try { + const usageData = await this.getUsageForCollection(collection, statsCollectionConfig); + if (usageData.length) { + this.logger.debug(`Got Usage using ${collection.title} collection.`); + if (config.unencrypted) { + return usageData; + } + return encryptTelemetry(usageData, this.isDev); + } + } catch (err) { + this.logger.debug( + `Failed to collect any usage with registered collection ${collection.title}.` + ); + // swallow error to try next collection; + } + } + + return []; + } + + private async getUsageForCollection( + collection: Collection, + statsCollectionConfig: StatsCollectionConfig + ): Promise<UsageStatsPayload[]> { + const context: StatsCollectionContext = { + logger: this.logger.get(collection.title), + isDev: this.isDev, + version: this.version, + ...collection.customContext, + }; + + const clustersDetails = await collection.clusterDetailsGetter(statsCollectionConfig, context); + + if (clustersDetails.length === 0) { + // don't bother doing a further lookup, try next collection. + return []; + } + + const [stats, licenses] = await Promise.all([ + collection.statsGetter(clustersDetails, statsCollectionConfig, context), + collection.licenseGetter(clustersDetails, statsCollectionConfig, context), + ]); + + return stats.map(stat => { + const license = licenses[stat.cluster_uuid]; + return { + ...(license ? { license } : {}), + ...stat, + collectionSource: collection.title, + }; + }); + } +} diff --git a/src/plugins/telemetry_collection_manager/server/types.ts b/src/plugins/telemetry_collection_manager/server/types.ts new file mode 100644 index 0000000000000..e23d6a4c388f4 --- /dev/null +++ b/src/plugins/telemetry_collection_manager/server/types.ts @@ -0,0 +1,150 @@ +/* + * 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 { APICaller, Logger, KibanaRequest, IClusterClient } from 'kibana/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { TelemetryCollectionManagerPlugin } from './plugin'; + +export interface TelemetryCollectionManagerPluginSetup { + setCollection: <CustomContext extends Record<string, any>, T extends BasicStatsPayload>( + collectionConfig: CollectionConfig<CustomContext, T> + ) => void; + getOptInStats: TelemetryCollectionManagerPlugin['getOptInStats']; + getStats: TelemetryCollectionManagerPlugin['getStats']; +} + +export interface TelemetryCollectionManagerPluginStart { + setCollection: <CustomContext extends Record<string, any>, T extends BasicStatsPayload>( + collectionConfig: CollectionConfig<CustomContext, T> + ) => void; + getOptInStats: TelemetryCollectionManagerPlugin['getOptInStats']; + getStats: TelemetryCollectionManagerPlugin['getStats']; +} + +export interface TelemetryOptInStats { + cluster_uuid: string; + opt_in_status: boolean; +} + +export interface BaseStatsGetterConfig { + unencrypted: boolean; + start: string; + end: string; + request?: KibanaRequest; +} + +export interface EncryptedStatsGetterConfig extends BaseStatsGetterConfig { + unencrypted: false; +} + +export interface UnencryptedStatsGetterConfig extends BaseStatsGetterConfig { + unencrypted: true; + request: KibanaRequest; +} + +export interface ClusterDetails { + clusterUuid: string; +} + +export interface StatsCollectionConfig { + usageCollection: UsageCollectionSetup; + callCluster: APICaller; + start: string | number; + end: string | number; +} + +export interface BasicStatsPayload { + timestamp: string; + cluster_uuid: string; + cluster_name: string; + version: string; + cluster_stats: object; + collection?: string; + stack_stats: object; +} + +export interface UsageStatsPayload extends BasicStatsPayload { + license?: ESLicense; + collectionSource: string; +} + +// From https://www.elastic.co/guide/en/elasticsearch/reference/current/get-license.html +export interface ESLicense { + status: string; + uid: string; + type: string; + issue_date: string; + issue_date_in_millis: number; + expiry_date: string; + expirty_date_in_millis: number; + max_nodes: number; + issued_to: string; + issuer: string; + start_date_in_millis: number; +} + +export interface StatsCollectionContext { + logger: Logger; + isDev: boolean; + version: string; +} + +export type StatsGetterConfig = UnencryptedStatsGetterConfig | EncryptedStatsGetterConfig; +export type ClusterDetailsGetter<CustomContext extends Record<string, any> = {}> = ( + config: StatsCollectionConfig, + context: StatsCollectionContext & CustomContext +) => Promise<ClusterDetails[]>; +export type StatsGetter< + CustomContext extends Record<string, any> = {}, + T extends BasicStatsPayload = BasicStatsPayload +> = ( + clustersDetails: ClusterDetails[], + config: StatsCollectionConfig, + context: StatsCollectionContext & CustomContext +) => Promise<T[]>; +export type LicenseGetter<CustomContext extends Record<string, any> = {}> = ( + clustersDetails: ClusterDetails[], + config: StatsCollectionConfig, + context: StatsCollectionContext & CustomContext +) => Promise<{ [clusterUuid: string]: ESLicense | undefined }>; + +export interface CollectionConfig< + CustomContext extends Record<string, any> = {}, + T extends BasicStatsPayload = BasicStatsPayload +> { + title: string; + priority: number; + esCluster: IClusterClient; + statsGetter: StatsGetter<CustomContext, T>; + clusterDetailsGetter: ClusterDetailsGetter<CustomContext>; + licenseGetter: LicenseGetter<CustomContext>; + customContext?: CustomContext; +} + +export interface Collection< + CustomContext extends Record<string, any> = {}, + T extends BasicStatsPayload = BasicStatsPayload +> { + customContext?: CustomContext; + statsGetter: StatsGetter<CustomContext, T>; + licenseGetter: LicenseGetter<CustomContext>; + clusterDetailsGetter: ClusterDetailsGetter<CustomContext>; + esCluster: IClusterClient; + title: string; +} diff --git a/src/plugins/telemetry_management_section/README.md b/src/plugins/telemetry_management_section/README.md new file mode 100644 index 0000000000000..0f795786720c9 --- /dev/null +++ b/src/plugins/telemetry_management_section/README.md @@ -0,0 +1,5 @@ +# Telemetry Management Section + +This plugin adds the Advanced Settings section for the Usage Data collection (aka Telemetry). + +The reason for having it separated from the `telemetry` plugin is to avoid circular dependencies. The plugin `advancedSettings` depends on the `home` app that depends on the `telemetry` plugin because of the telemetry banner in the welcome screen. diff --git a/src/plugins/telemetry_management_section/kibana.json b/src/plugins/telemetry_management_section/kibana.json new file mode 100644 index 0000000000000..3364833def4d6 --- /dev/null +++ b/src/plugins/telemetry_management_section/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "telemetryManagementSection", + "version": "kibana", + "server": false, + "ui": true, + "requiredPlugins": [ + "advancedSettings", + "telemetry" + ] +} diff --git a/src/plugins/telemetry/public/components/__snapshots__/opt_in_example_flyout.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/opt_in_example_flyout.test.tsx.snap similarity index 100% rename from src/plugins/telemetry/public/components/__snapshots__/opt_in_example_flyout.test.tsx.snap rename to src/plugins/telemetry_management_section/public/components/__snapshots__/opt_in_example_flyout.test.tsx.snap diff --git a/src/plugins/telemetry_management_section/public/components/index.ts b/src/plugins/telemetry_management_section/public/components/index.ts new file mode 100644 index 0000000000000..86954744e7a01 --- /dev/null +++ b/src/plugins/telemetry_management_section/public/components/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { OptInExampleFlyout } from './opt_in_example_flyout'; +export { telemetryManagementSectionWrapper } from './telemetry_management_section_wrapper'; +export { TelemetryManagementSection } from './telemetry_management_section'; diff --git a/src/plugins/telemetry/public/components/opt_in_example_flyout.test.tsx b/src/plugins/telemetry_management_section/public/components/opt_in_example_flyout.test.tsx similarity index 100% rename from src/plugins/telemetry/public/components/opt_in_example_flyout.test.tsx rename to src/plugins/telemetry_management_section/public/components/opt_in_example_flyout.test.tsx diff --git a/src/plugins/telemetry/public/components/opt_in_example_flyout.tsx b/src/plugins/telemetry_management_section/public/components/opt_in_example_flyout.tsx similarity index 100% rename from src/plugins/telemetry/public/components/opt_in_example_flyout.tsx rename to src/plugins/telemetry_management_section/public/components/opt_in_example_flyout.tsx diff --git a/src/plugins/telemetry/public/components/telemetry_management_section.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx similarity index 96% rename from src/plugins/telemetry/public/components/telemetry_management_section.tsx rename to src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx index bf14c33a48048..26e075b666593 100644 --- a/src/plugins/telemetry/public/components/telemetry_management_section.tsx +++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx @@ -31,11 +31,14 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { PRIVACY_STATEMENT_URL } from '../../common/constants'; +import { TelemetryPluginSetup } from 'src/plugins/telemetry/public'; +import { PRIVACY_STATEMENT_URL } from '../../../telemetry/common/constants'; import { OptInExampleFlyout } from './opt_in_example_flyout'; import { Field } from '../../../advanced_settings/public'; -import { ToastsStart } from '../../../../core/public/'; -import { TelemetryService } from '../services/telemetry_service'; +import { ToastsStart } from '../../../../core/public'; + +type TelemetryService = TelemetryPluginSetup['telemetryService']; + const SEARCH_TERMS = ['telemetry', 'usage', 'data', 'usage data']; interface Props { diff --git a/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx new file mode 100644 index 0000000000000..b8b20b68f666e --- /dev/null +++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx @@ -0,0 +1,40 @@ +/* + * 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 from 'react'; +import { TelemetryPluginSetup } from 'src/plugins/telemetry/public'; +import { TelemetryManagementSection } from './telemetry_management_section'; + +// It should be this but the types are way too vague in the AdvancedSettings plugin `Record<string, any>` +// type Props = Omit<TelemetryManagementSection['props'], 'telemetryService'>; +type Props = any; + +export function telemetryManagementSectionWrapper( + telemetryService: TelemetryPluginSetup['telemetryService'] +) { + const TelemetryManagementSectionWrapper = (props: Props) => ( + <TelemetryManagementSection + showAppliesSettingMessage={true} + telemetryService={telemetryService} + {...props} + /> + ); + + return TelemetryManagementSectionWrapper; +} diff --git a/src/legacy/server/i18n/constants.js b/src/plugins/telemetry_management_section/public/index.ts similarity index 85% rename from src/legacy/server/i18n/constants.js rename to src/plugins/telemetry_management_section/public/index.ts index a7a410dbcb5b3..6a80cdd98b1a3 100644 --- a/src/legacy/server/i18n/constants.js +++ b/src/plugins/telemetry_management_section/public/index.ts @@ -17,4 +17,8 @@ * under the License. */ -export const I18N_RC = '.i18nrc.json'; +import { TelemetryManagementSectionPlugin } from './plugin'; + +export function plugin() { + return new TelemetryManagementSectionPlugin(); +} diff --git a/src/plugins/telemetry_management_section/public/plugin.ts b/src/plugins/telemetry_management_section/public/plugin.ts new file mode 100644 index 0000000000000..738b38c36d30d --- /dev/null +++ b/src/plugins/telemetry_management_section/public/plugin.ts @@ -0,0 +1,54 @@ +/* + * 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 { AdvancedSettingsSetup } from 'src/plugins/advanced_settings/public'; +import { TelemetryPluginSetup } from 'src/plugins/telemetry/public'; +import { Plugin, CoreStart, CoreSetup } from '../../../core/public'; + +import { telemetryManagementSectionWrapper } from './components/telemetry_management_section_wrapper'; + +export interface TelemetryPluginConfig { + enabled: boolean; + url: string; + banner: boolean; + allowChangingOptInStatus: boolean; + optIn: boolean | null; + optInStatusUrl: string; + sendUsageFrom: 'browser' | 'server'; + telemetryNotifyUserAboutOptInDefault?: boolean; +} + +export interface TelemetryManagementSectionPluginDepsSetup { + telemetry: TelemetryPluginSetup; + advancedSettings: AdvancedSettingsSetup; +} + +export class TelemetryManagementSectionPlugin implements Plugin { + public setup( + core: CoreSetup, + { advancedSettings, telemetry: { telemetryService } }: TelemetryManagementSectionPluginDepsSetup + ) { + advancedSettings.component.register( + advancedSettings.component.componentType.PAGE_FOOTER_COMPONENT, + telemetryManagementSectionWrapper(telemetryService), + true + ); + } + + public start(core: CoreStart) {} +} diff --git a/src/plugins/usage_collection/README.md b/src/plugins/usage_collection/README.md index 4b81fe9b22083..1c97c9c63c0e2 100644 --- a/src/plugins/usage_collection/README.md +++ b/src/plugins/usage_collection/README.md @@ -10,37 +10,100 @@ To integrate with the telemetry services for usage collection of your feature, t All you need to provide is a `type` for organizing your fields, and a `fetch` method for returning your usage data. Then you need to make the Telemetry service aware of the collector by registering it. -### New Platform: +### New Platform 1. Make sure `usageCollection` is in your optional Plugins: -```json -// plugin/kibana.json -{ - "id": "...", - "optionalPlugins": ["usageCollection"] -} -``` + ```json + // plugin/kibana.json + { + "id": "...", + "optionalPlugins": ["usageCollection"] + } + ``` 2. Register Usage collector in the `setup` function: + ```ts + // server/plugin.ts + import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; + import { CoreSetup, CoreStart } from 'kibana/server'; + + class Plugin { + public setup(core: CoreSetup, plugins: { usageCollection?: UsageCollectionSetup }) { + registerMyPluginUsageCollector(plugins.usageCollection); + } + + public start(core: CoreStart) {} + } + ``` + +3. Creating and registering a Usage Collector. Ideally collectors would be defined in a separate directory `server/collectors/register.ts`. + + ```ts + // server/collectors/register.ts + import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; + import { APICluster } from 'kibana/server'; + + export function registerMyPluginUsageCollector(usageCollection?: UsageCollectionSetup): void { + // usageCollection is an optional dependency, so make sure to return if it is not registered. + if (!usageCollection) { + return; + } + + // create usage collector + const myCollector = usageCollection.makeUsageCollector({ + type: MY_USAGE_TYPE, + fetch: async (callCluster: APICluster) => { + + // query ES and get some data + // summarize the data into a model + // return the modeled object that includes whatever you want to track + + return { + my_objects: { + total: SOME_NUMBER + } + }; + }, + }); + + // register usage collector + usageCollection.registerCollector(myCollector); + } + ``` + +Some background: The `callCluster` that gets passed to the `fetch` method is created in a way that's a bit tricky, to support multiple contexts the `fetch` method could be called. Your `fetch` method could get called as a result of an HTTP API request: in this case, the `callCluster` function wraps `callWithRequest`, and the request headers are expected to have read privilege on the entire `.kibana` index. The use case for this is stats pulled from a Kibana Metricbeat module, where the Beat calls Kibana's stats API in Kibana to invoke collection. + +Note: there will be many cases where you won't need to use the `callCluster` function that gets passed in to your `fetch` method at all. Your feature might have an accumulating value in server memory, or read something from the OS, or use other clients like a custom SavedObjects client. In that case it's up to the plugin to initialize those clients like the example below: + ```ts // server/plugin.ts +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { CoreSetup, CoreStart } from 'kibana/server'; + class Plugin { - setup(core, plugins) { - registerMyPluginUsageCollector(plugins.usageCollection); + private savedObjectsClient?: ISavedObjectsRepository; + + public setup(core: CoreSetup, plugins: { usageCollection?: UsageCollectionSetup }) { + registerMyPluginUsageCollector(() => this.savedObjectsClient, plugins.usageCollection); + } + + public start(core: CoreStart) { + this.savedObjectsClient = core.savedObjects.client } } ``` -3. Creating and registering a Usage Collector. Ideally collectors would be defined in a separate directory `server/collectors/register.ts`. - ```ts // server/collectors/register.ts import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { ISavedObjectsRepository } from 'kibana/server'; -export function registerMyPluginUsageCollector(usageCollection?: UsageCollectionSetup): void { +export function registerMyPluginUsageCollector( + getSavedObjectsClient: () => ISavedObjectsRepository | undefined, + usageCollection?: UsageCollectionSetup + ): void { // usageCollection is an optional dependency, so make sure to return if it is not registered. if (!usageCollection) { return; @@ -49,17 +112,12 @@ export function registerMyPluginUsageCollector(usageCollection?: UsageCollection // create usage collector const myCollector = usageCollection.makeUsageCollector({ type: MY_USAGE_TYPE, - fetch: async (callCluster: CallCluster) => { + isReady: () => typeof getSavedObjectsClient() !== 'undefined', + fetch: async () => { + const savedObjectsClient = getSavedObjectsClient()!; + // get something from the savedObjects - // query ES and get some data - // summarize the data into a model - // return the modeled object that includes whatever you want to track - - return { - my_objects: { - total: SOME_NUMBER - } - }; + return { my_objects }; }, }); @@ -68,11 +126,7 @@ export function registerMyPluginUsageCollector(usageCollection?: UsageCollection } ``` -Some background: The `callCluster` that gets passed to the `fetch` method is created in a way that's a bit tricky, to support multiple contexts the `fetch` method could be called. Your `fetch` method could get called as a result of an HTTP API request: in this case, the `callCluster` function wraps `callWithRequest`, and the request headers are expected to have read privilege on the entire `.kibana` index. The use case for this is stats pulled from a Kibana Metricbeat module, where the Beat calls Kibana's stats API in Kibana to invoke collection. - -Note: there will be many cases where you won't need to use the `callCluster` function that gets passed in to your `fetch` method at all. Your feature might have an accumulating value in server memory, or read something from the OS. - -### Migrating to NP from Legacy Plugins: +### Migrating to NP from Legacy Plugins Pass `usageCollection` to the setup NP plugin setup function under plugins. Inside the `setup` function call the `registerCollector` like what you'd do in the NP example above. @@ -91,7 +145,7 @@ export const myPlugin = (kibana: any) => { } ``` -### Legacy Plugins: +### Legacy Plugins Typically, a plugin will create the collector object and register it with the Telemetry service from the `init` method of the plugin definition, or a helper module called from `init`. @@ -109,7 +163,7 @@ export const myPlugin = (kibana: any) => { ## Update the telemetry payload and telemetry cluster field mappings -There is a module in the telemetry service that creates the payload of data that gets sent up to the telemetry cluster. +There is a module in the telemetry service that creates the payload of data that gets sent up to the telemetry cluster. New fields added to the telemetry payload currently mean that telemetry cluster field mappings have to be updated, so they can be searched and aggregated in Kibana visualizations. This is also a short-term obligation. In the next refactoring phase, collectors will need to use a proscribed data model that eliminates maintenance of mappings in the telemetry cluster. @@ -122,12 +176,14 @@ There are a few ways you can test that your usage collector is working properly. - The `.monitoring-*` indices, when Monitoring is enabled. Monitoring enhances the sent payload of telemetry by producing usage data potentially of multiple clusters that exist in the monitoring data. Monitoring data is time-based, and the time frame of collection is the last 15 minutes. - Live-pulled from ES API endpoints. This will get just real-time stats without context of historical data. - The dev script in x-pack can be run on the command-line with: - ``` + + ```shell cd x-pack node scripts/api_debug.js telemetry --host=http://localhost:5601 ``` + Where `http://localhost:5601` is a Kibana server running in dev mode. If needed, authentication and basePath info can be provided in the command as well. - - Automatic inclusion of all the stats fetched by collectors is added in https://github.com/elastic/kibana/pull/22336 / 6.5.0 + - Automatic inclusion of all the stats fetched by collectors is added in [#22336](https://github.com/elastic/kibana/pull/22336) / 6.5.0 3. In Dev mode, Kibana will send telemetry data to a staging telemetry cluster. Assuming you have access to the staging cluster, you can log in and check the latest documents for your new fields. 4. If you catch the network traffic coming from your browser when a telemetry payload is sent, you can examine the request payload body to see the data. This can be tricky as telemetry payloads are sent only once per day per browser. Use incognito mode or clear your localStorage data to force a telemetry payload. @@ -157,7 +213,33 @@ the name of a dashboard they've viewed, or the timestamp of the interaction. ## How to use it -To track a user interaction, import the `createUiStatsReporter` helper function from UI Metric app: +To track a user interaction, use the `reportUiStats` method exposed by the plugin `usageCollection` in the public side: + +1. Similarly to the server-side usage collection, make sure `usageCollection` is in your optional Plugins: + + ```json + // plugin/kibana.json + { + "id": "...", + "optionalPlugins": ["usageCollection"] + } + ``` + +2. Register Usage collector in the `setup` function: + + ```ts + // public/plugin.ts + class Plugin { + setup(core, { usageCollection }) { + if (usageCollection) { + // Call the following method as many times as you want to report an increase in the count for this event + usageCollection.reportUiStats(`<AppName>`, usageCollection.METRIC_TYPE.CLICK, `<EventName>`); + } + } + } + ``` + +Alternatively, in the Legacy world you can still import the `createUiStatsReporter` helper function from UI Metric app: ```js import { createUiStatsReporter, METRIC_TYPE } from 'relative/path/to/src/legacy/core_plugins/ui_metric/public'; @@ -167,9 +249,10 @@ trackMetric('click', `<EventName>`); ``` Metric Types: - - `METRIC_TYPE.CLICK` for tracking clicks `trackMetric(METRIC_TYPE.CLICK, 'my_button_clicked');` - - `METRIC_TYPE.LOADED` for a component load or page load `trackMetric(METRIC_TYPE.LOADED', 'my_component_loaded');` - - `METRIC_TYPE.COUNT` for a tracking a misc count `trackMetric(METRIC_TYPE.COUNT', 'my_counter', <count> });` + +- `METRIC_TYPE.CLICK` for tracking clicks `trackMetric(METRIC_TYPE.CLICK, 'my_button_clicked');` +- `METRIC_TYPE.LOADED` for a component load or page load `trackMetric(METRIC_TYPE.LOADED', 'my_component_loaded');` +- `METRIC_TYPE.COUNT` for a tracking a misc count `trackMetric(METRIC_TYPE.COUNT', 'my_counter', <count> });` Call this function whenever you would like to track a user interaction within your app. The function accepts two arguments, `metricType` and `eventNames`. These should be underscore-delimited strings. @@ -196,7 +279,7 @@ use a `eventName` of `create_vis_1m`, `create_vis_5m`, `create_vis_20m`, or `cr ## How it works Under the hood, your app and metric type will be stored in a saved object of type `user-metric` and the -ID `ui-metric:my_app:my_metric`. This saved object will have a `count` property which will be incremented +ID `ui-metric:my_app:my_metric`. This saved object will have a `count` property which will be incremented every time the above URI is hit. These saved objects are automatically consumed by the stats API and surfaced under the @@ -216,4 +299,6 @@ These saved objects are automatically consumed by the stats API and surfaced und ``` By storing these metrics and their counts as key-value pairs, we can add more metrics without having -to worry about exceeding the 1000-field soft limit in Elasticsearch. \ No newline at end of file +to worry about exceeding the 1000-field soft limit in Elasticsearch. + +The only caveat is that it makes it harder to consume in Kibana when analysing each entry in the array separately. In the telemetry team we are working to find a solution to this. We are building a new way of reporting telemetry called [Pulse](../../../rfcs/text/0008_pulse.md) that will help on making these UI-Metrics easier to consume. diff --git a/src/plugins/usage_collection/server/collector/collector.ts b/src/plugins/usage_collection/server/collector/collector.ts index 91951aa2f3edf..b4f86f67e798d 100644 --- a/src/plugins/usage_collection/server/collector/collector.ts +++ b/src/plugins/usage_collection/server/collector/collector.ts @@ -17,15 +17,14 @@ * under the License. */ -import { Logger } from 'kibana/server'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { Logger, APICaller } from 'kibana/server'; export type CollectorFormatForBulkUpload<T, U> = (result: T) => { type: string; payload: U }; export interface CollectorOptions<T = unknown, U = T> { type: string; init?: Function; - fetch: (callCluster: CallCluster) => Promise<T> | T; + fetch: (callCluster: APICaller) => Promise<T> | T; /* * A hook for allowing the fetched data payload to be organized into a typed * data model for internal bulk upload. See defaultFormatterForBulkUpload for diff --git a/src/plugins/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts index 6cc5d057b080a..64a48025be248 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.ts @@ -18,8 +18,7 @@ */ import { snakeCase } from 'lodash'; -import { Logger } from 'kibana/server'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { Logger, APICaller } from 'kibana/server'; import { Collector, CollectorOptions } from './collector'; import { UsageCollector } from './usage_collector'; @@ -31,7 +30,7 @@ interface CollectorSetConfig { export class CollectorSet { private _waitingForAllCollectorsTimestamp?: number; - private logger: Logger; + private readonly logger: Logger; private readonly maximumWaitTimeForAllCollectorsInS: number; private collectors: Array<Collector<any, any>> = []; constructor({ logger, maximumWaitTimeForAllCollectorsInS, collectors = [] }: CollectorSetConfig) { @@ -112,7 +111,7 @@ export class CollectorSet { }; public bulkFetch = async ( - callCluster: CallCluster, + callCluster: APICaller, collectors: Array<Collector<any, any>> = this.collectors ) => { const responses = []; @@ -135,13 +134,13 @@ export class CollectorSet { /* * @return {new CollectorSet} */ - public getFilteredCollectorSet = (filter: any) => { + public getFilteredCollectorSet = (filter: (col: Collector) => boolean) => { const filtered = this.collectors.filter(filter); return this.makeCollectorSetFromArray(filtered); }; - public bulkFetchUsage = async (callCluster: CallCluster) => { - const usageCollectors = this.getFilteredCollectorSet((c: any) => c instanceof UsageCollector); + public bulkFetchUsage = async (callCluster: APICaller) => { + const usageCollectors = this.getFilteredCollectorSet(c => c instanceof UsageCollector); return await this.bulkFetch(callCluster, usageCollectors.collectors); }; diff --git a/src/plugins/usage_collection/server/index.ts b/src/plugins/usage_collection/server/index.ts index 6a28dba50a915..a2769c8b4b405 100644 --- a/src/plugins/usage_collection/server/index.ts +++ b/src/plugins/usage_collection/server/index.ts @@ -17,7 +17,7 @@ * under the License. */ -import { PluginInitializerContext } from '../../../../src/core/server'; +import { PluginInitializerContext } from 'kibana/server'; import { UsageCollectionPlugin } from './plugin'; export { UsageCollectionSetup } from './plugin'; diff --git a/src/plugins/usage_collection/server/plugin.ts b/src/plugins/usage_collection/server/plugin.ts index 52acb5b3fc86f..00584e1fd5d86 100644 --- a/src/plugins/usage_collection/server/plugin.ts +++ b/src/plugins/usage_collection/server/plugin.ts @@ -18,15 +18,21 @@ */ import { first } from 'rxjs/operators'; -import { CoreStart, ISavedObjectsRepository } from 'kibana/server'; +import { + PluginInitializerContext, + Logger, + CoreSetup, + CoreStart, + ISavedObjectsRepository, + Plugin, +} from 'kibana/server'; import { ConfigType } from './config'; -import { PluginInitializerContext, Logger, CoreSetup } from '../../../../src/core/server'; import { CollectorSet } from './collector'; import { setupRoutes } from './routes'; export type UsageCollectionSetup = CollectorSet; -export class UsageCollectionPlugin { - logger: Logger; +export class UsageCollectionPlugin implements Plugin<CollectorSet> { + private readonly logger: Logger; private savedObjects?: ISavedObjectsRepository; constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); @@ -39,7 +45,7 @@ export class UsageCollectionPlugin { .toPromise(); const collectorSet = new CollectorSet({ - logger: this.logger, + logger: this.logger.get('collector-set'), maximumWaitTimeForAllCollectorsInS: config.maximumWaitTimeForAllCollectorsInS, }); diff --git a/test/tsconfig.json b/test/tsconfig.json index 71c9e375a4124..285d3db64a874 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -14,9 +14,10 @@ "**/*.ts", "**/*.tsx", "../typings/lodash.topath/*.ts", + "../typings/elastic__node_crypto.d.ts", "typings/**/*" ], "exclude": [ "plugin_functional/plugins/**/*" ] -} \ No newline at end of file +} diff --git a/x-pack/legacy/plugins/monitoring/index.js b/x-pack/legacy/plugins/monitoring/index.js index fcf704b5f65d3..ccb45dc1f446f 100644 --- a/x-pack/legacy/plugins/monitoring/index.js +++ b/x-pack/legacy/plugins/monitoring/index.js @@ -9,7 +9,6 @@ import { resolve } from 'path'; import { config } from './config'; import { getUiExports } from './ui_exports'; import { KIBANA_ALERTING_ENABLED } from './common/constants'; -import { telemetryCollectionManager } from '../../../../src/legacy/core_plugins/telemetry/server'; /** * Invokes plugin modules to instantiate the Monitoring plugin for Kibana @@ -32,7 +31,6 @@ export const monitoring = kibana => { if (npMonitoring) { const kbnServerStatus = this.kbnServer.status; npMonitoring.registerLegacyAPI({ - telemetryCollectionManager, getServerStatus: () => { const status = kbnServerStatus.toJSON(); return get(status, 'overall.state'); diff --git a/x-pack/legacy/plugins/xpack_main/index.js b/x-pack/legacy/plugins/xpack_main/index.js index 809d90d58d796..4cf32b971b57e 100644 --- a/x-pack/legacy/plugins/xpack_main/index.js +++ b/x-pack/legacy/plugins/xpack_main/index.js @@ -12,7 +12,6 @@ import { setupXPackMain } from './server/lib/setup_xpack_main'; import { xpackInfoRoute, settingsRoute } from './server/routes/api/v1'; export { callClusterFactory } from './server/lib/call_cluster_factory'; -import { registerMonitoringCollection } from './server/telemetry_collection'; export const xpackMain = kibana => { return new kibana.Plugin({ @@ -69,7 +68,6 @@ export const xpackMain = kibana => { } mirrorPluginStatus(server.plugins.elasticsearch, this, 'yellow', 'red'); - registerMonitoringCollection(); featuresPlugin.registerLegacyAPI({ xpackInfo: setupXPackMain(server), diff --git a/x-pack/legacy/plugins/xpack_main/server/telemetry_collection/register_xpack_collection.ts b/x-pack/legacy/plugins/xpack_main/server/telemetry_collection/register_xpack_collection.ts deleted file mode 100644 index 04445d7bde7d7..0000000000000 --- a/x-pack/legacy/plugins/xpack_main/server/telemetry_collection/register_xpack_collection.ts +++ /dev/null @@ -1,21 +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 { getLocalLicense } from '../../../../../../src/legacy/core_plugins/telemetry/server/telemetry_collection/get_local_license'; -import { telemetryCollectionManager } from '../../../../../../src/legacy/core_plugins/telemetry/server'; -import { getClusterUuids } from '../../../../../../src/legacy/core_plugins/telemetry/server/telemetry_collection'; -import { getStatsWithXpack } from './get_stats_with_xpack'; - -export function registerMonitoringCollection() { - telemetryCollectionManager.setCollection({ - esCluster: 'data', - title: 'local_xpack', - priority: 1, - statsGetter: getStatsWithXpack, - clusterDetailsGetter: getClusterUuids, - licenseGetter: getLocalLicense, - }); -} diff --git a/x-pack/plugins/license_management/public/application/lib/telemetry.ts b/x-pack/plugins/license_management/public/application/lib/telemetry.ts index 1d90fce6f6b9a..823680a36dccb 100644 --- a/x-pack/plugins/license_management/public/application/lib/telemetry.ts +++ b/x-pack/plugins/license_management/public/application/lib/telemetry.ts @@ -6,7 +6,7 @@ import { TelemetryPluginSetup } from '../../../../../../src/plugins/telemetry/public'; -export { OptInExampleFlyout } from '../../../../../../src/plugins/telemetry/public/components'; +export { OptInExampleFlyout } from '../../../../../../src/plugins/telemetry_management_section/public/components'; export { PRIVACY_STATEMENT_URL } from '../../../../../../src/plugins/telemetry/common/constants'; export { TelemetryPluginSetup, shouldShowTelemetryOptIn }; diff --git a/x-pack/plugins/monitoring/kibana.json b/x-pack/plugins/monitoring/kibana.json index 8d69937c3677d..bbdf1a2e7cb76 100644 --- a/x-pack/plugins/monitoring/kibana.json +++ b/x-pack/plugins/monitoring/kibana.json @@ -3,8 +3,8 @@ "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["monitoring"], - "requiredPlugins": ["usageCollection", "licensing", "features"], - "optionalPlugins": ["alerting", "actions", "infra"], + "requiredPlugins": ["licensing", "features"], + "optionalPlugins": ["alerting", "actions", "infra", "telemetryCollectionManager", "usageCollection"], "server": true, "ui": false } diff --git a/x-pack/plugins/monitoring/server/plugin.ts b/x-pack/plugins/monitoring/server/plugin.ts index bd1455a2c582f..ca5ef7f9db26b 100644 --- a/x-pack/plugins/monitoring/server/plugin.ts +++ b/x-pack/plugins/monitoring/server/plugin.ts @@ -9,7 +9,7 @@ import { first } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; import { has, get } from 'lodash'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { TelemetryCollectionManager } from 'src/legacy/core_plugins/telemetry/server/collection_manager'; +import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; import { LOGGING_TAG, KIBANA_MONITORING_LOGGING_TAG, @@ -50,13 +50,13 @@ import { getLicenseExpiration } from './alerts/license_expiration'; import { InfraPluginSetup } from '../../infra/server'; export interface LegacyAPI { - telemetryCollectionManager: TelemetryCollectionManager; getServerStatus: () => string; infra: any; } interface PluginsSetup { - usageCollection: UsageCollectionSetup; + telemetryCollectionManager?: TelemetryCollectionManagerPluginSetup; + usageCollection?: UsageCollectionSetup; licensing: LicensingPluginSetup; features: FeaturesPluginSetupContract; alerting: AlertingPluginSetupContract; @@ -120,7 +120,7 @@ export class Plugin { router: core.http.createRouter(), instanceUuid: core.uuid.getInstanceUuid(), esDataClient: core.elasticsearch.dataClient, - kibanaStatsCollector: plugins.usageCollection.getCollectorByType( + kibanaStatsCollector: plugins.usageCollection?.getCollectorByType( KIBANA_STATS_TYPE_MONITORING ), }; @@ -157,13 +157,22 @@ export class Plugin { ); } + // Initialize telemetry + if (plugins.telemetryCollectionManager) { + registerMonitoringCollection(plugins.telemetryCollectionManager, this.cluster, { + maxBucketSize: config.ui.max_bucket_size, + }); + } + // Register collector objects for stats to show up in the APIs - registerCollectors( - plugins.usageCollection, - config, - core.metrics.getOpsMetrics$(), - get(legacyConfig, 'kibana.index') - ); + if (plugins.usageCollection) { + registerCollectors( + plugins.usageCollection, + config, + core.metrics.getOpsMetrics$(), + get(legacyConfig, 'kibana.index') + ); + } // If collection is enabled, create the bulk uploader const kibanaMonitoringLog = this.getLogger(KIBANA_MONITORING_LOGGING_TAG); @@ -275,9 +284,6 @@ export class Plugin { } async setupLegacy(legacyAPI: LegacyAPI) { - // Initialize telemetry - registerMonitoringCollection(this.cluster, legacyAPI.telemetryCollectionManager); - // Set the stats getter this.bulkUploader.setKibanaStatusGetter(() => legacyAPI.getServerStatus()); } diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts index dcc7924fe171a..1a9f2a4da32c2 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts @@ -9,26 +9,12 @@ import { getStackStats, getAllStats, handleAllStats } from './get_all_stats'; import { ESClusterStats } from './get_es_stats'; import { KibanaStats } from './get_kibana_stats'; import { ClustersHighLevelStats } from './get_high_level_stats'; +import { coreMock } from 'src/core/server/mocks'; describe('get_all_stats', () => { - const size = 123; const start = 0; const end = 1; const callCluster = sinon.stub(); - const server = { - config: sinon.stub().returns({ - get: sinon - .stub() - .withArgs('xpack.monitoring.elasticsearch.index_pattern') - .returns('.monitoring-es-N-*') - .withArgs('xpack.monitoring.kibana.index_pattern') - .returns('.monitoring-kibana-N-*') - .withArgs('xpack.monitoring.logstash.index_pattern') - .returns('.monitoring-logstash-N-*') - .withArgs('xpack.monitoring.max_bucket_size') - .returns(size), - }), - }; const esClusters = [ { cluster_uuid: 'a' }, @@ -186,13 +172,21 @@ describe('get_all_stats', () => { .returns(Promise.resolve({})); // Beats state expect( - await getAllStats([{ clusterUuid: 'a' }], { - callCluster: callCluster as any, - usageCollection: {} as any, - server, - start, - end, - }) + await getAllStats( + [{ clusterUuid: 'a' }], + { + callCluster: callCluster as any, + usageCollection: {} as any, + start, + end, + }, + { + logger: coreMock.createPluginInitializerContext().logger.get('test'), + isDev: true, + version: 'version', + maxBucketSize: 1, + } + ) ).toStrictEqual(allClusters); }); @@ -204,13 +198,21 @@ describe('get_all_stats', () => { callCluster.withArgs('search').returns(Promise.resolve(clusterUuidsResponse)); expect( - await getAllStats([], { - callCluster: callCluster as any, - usageCollection: {} as any, - server, - start, - end, - }) + await getAllStats( + [], + { + callCluster: callCluster as any, + usageCollection: {} as any, + start, + end, + }, + { + logger: coreMock.createPluginInitializerContext().logger.get('test'), + isDev: true, + version: 'version', + maxBucketSize: 1, + } + ) ).toStrictEqual([]); }); }); diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.ts index a6ed5254dabd5..b180730acfd36 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.ts @@ -6,7 +6,7 @@ import { get, set, merge } from 'lodash'; -import { StatsGetter } from 'src/legacy/core_plugins/telemetry/server/collection_manager'; +import { StatsGetter } from 'src/plugins/telemetry_collection_manager/server'; import { LOGSTASH_SYSTEM_ID, KIBANA_SYSTEM_ID, BEATS_SYSTEM_ID } from '../../common/constants'; import { getElasticsearchStats, ESClusterStats } from './get_es_stats'; import { getKibanaStats, KibanaStats } from './get_kibana_stats'; @@ -17,22 +17,27 @@ type PromiseReturnType<T extends (...args: any[]) => any> = ReturnType<T> extend ? R : T; +export interface CustomContext { + maxBucketSize: number; +} + /** * Get statistics for all products joined by Elasticsearch cluster. * Returns the array of clusters joined with the Kibana and Logstash instances. * */ -export const getAllStats: StatsGetter = async ( +export const getAllStats: StatsGetter<CustomContext> = async ( clustersDetails, - { server, callCluster, start, end } + { callCluster, start, end }, + { maxBucketSize } ) => { const clusterUuids = clustersDetails.map(clusterDetails => clusterDetails.clusterUuid); const [esClusters, kibana, logstash, beats] = await Promise.all([ - getElasticsearchStats(server, callCluster, clusterUuids), // cluster_stats, stack_stats.xpack, cluster_name/uuid, license, version - getKibanaStats(server, callCluster, clusterUuids, start, end), // stack_stats.kibana - getHighLevelStats(server, callCluster, clusterUuids, start, end, LOGSTASH_SYSTEM_ID), // stack_stats.logstash - getBeatsStats(server, callCluster, clusterUuids, start, end), // stack_stats.beats + getElasticsearchStats(callCluster, clusterUuids, maxBucketSize), // cluster_stats, stack_stats.xpack, cluster_name/uuid, license, version + getKibanaStats(callCluster, clusterUuids, start, end, maxBucketSize), // stack_stats.kibana + getHighLevelStats(callCluster, clusterUuids, start, end, LOGSTASH_SYSTEM_ID, maxBucketSize), // stack_stats.logstash + getBeatsStats(callCluster, clusterUuids, start, end), // stack_stats.beats ]); return handleAllStats(esClusters, { kibana, logstash, beats }); diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_beats_stats.test.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_beats_stats.test.ts index 310c01571c71d..c619db90c8975 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_beats_stats.test.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_beats_stats.test.ts @@ -22,18 +22,16 @@ describe('Get Beats Stats', () => { const clusterUuids = ['aCluster', 'bCluster', 'cCluster']; const start = 100; const end = 200; - let server = { config: () => ({ get: sinon.stub() }) }; let callCluster = sinon.stub(); beforeEach(() => { const getStub = { get: sinon.stub() }; getStub.get.withArgs('xpack.monitoring.beats.index_pattern').returns('beats-indices-*'); - server = { config: () => getStub }; callCluster = sinon.stub(); }); it('should set `from: 0, to: 10000` in the query', async () => { - await fetchBeatsStats(server, callCluster, clusterUuids, start, end, {} as any); + await fetchBeatsStats(callCluster, clusterUuids, start, end, {} as any); const { args } = callCluster.firstCall; const [api, { body }] = args; @@ -43,7 +41,7 @@ describe('Get Beats Stats', () => { }); it('should set `from: 10000, from: 10000` in the query', async () => { - await fetchBeatsStats(server, callCluster, clusterUuids, start, end, { page: 1 } as any); + await fetchBeatsStats(callCluster, clusterUuids, start, end, { page: 1 } as any); const { args } = callCluster.firstCall; const [api, { body }] = args; @@ -53,7 +51,7 @@ describe('Get Beats Stats', () => { }); it('should set `from: 20000, from: 10000` in the query', async () => { - await fetchBeatsStats(server, callCluster, clusterUuids, start, end, { page: 2 } as any); + await fetchBeatsStats(callCluster, clusterUuids, start, end, { page: 2 } as any); const { args } = callCluster.firstCall; const [api, { body }] = args; diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_beats_stats.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_beats_stats.ts index cd588d7a90355..bf904e46516e0 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_beats_stats.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_beats_stats.ts @@ -5,8 +5,8 @@ */ import { get } from 'lodash'; -import { StatsCollectionConfig } from 'src/legacy/core_plugins/telemetry/server/collection_manager'; import { SearchResponse } from 'elasticsearch'; +import { StatsCollectionConfig } from 'src/plugins/telemetry_collection_manager/server'; import { createQuery } from './create_query'; import { INDEX_PATTERN_BEATS } from '../../common/constants'; @@ -308,7 +308,6 @@ export function processResults( /* * Create a set of result objects where each is the result of searching hits from Elasticsearch with a size of HITS_SIZE each time. - * @param {Object} server - The server instance * @param {function} callCluster - The callWithRequest or callWithInternalUser handler * @param {Array} clusterUuids - The string Cluster UUIDs to fetch details for * @param {Date} start - Start time to limit the stats @@ -319,7 +318,6 @@ export function processResults( * @return {Promise} */ async function fetchBeatsByType( - server: StatsCollectionConfig['server'], callCluster: StatsCollectionConfig['callCluster'], clusterUuids: string[], start: StatsCollectionConfig['start'], @@ -376,7 +374,7 @@ async function fetchBeatsByType( }; // returns a promise and keeps the caller blocked from returning until the entire clusters object is built - return fetchBeatsByType(server, callCluster, clusterUuids, start, end, nextOptions, type); + return fetchBeatsByType(callCluster, clusterUuids, start, end, nextOptions, type); } } @@ -384,25 +382,23 @@ async function fetchBeatsByType( } export async function fetchBeatsStats( - server: StatsCollectionConfig['server'], callCluster: StatsCollectionConfig['callCluster'], clusterUuids: string[], start: StatsCollectionConfig['start'], end: StatsCollectionConfig['end'], options: { page?: number } & BeatsProcessOptions ) { - return fetchBeatsByType(server, callCluster, clusterUuids, start, end, options, 'beats_stats'); + return fetchBeatsByType(callCluster, clusterUuids, start, end, options, 'beats_stats'); } export async function fetchBeatsStates( - server: StatsCollectionConfig['server'], callCluster: StatsCollectionConfig['callCluster'], clusterUuids: string[], start: StatsCollectionConfig['start'], end: StatsCollectionConfig['end'], options: { page?: number } & BeatsProcessOptions ) { - return fetchBeatsByType(server, callCluster, clusterUuids, start, end, options, 'beats_state'); + return fetchBeatsByType(callCluster, clusterUuids, start, end, options, 'beats_state'); } /* @@ -410,7 +406,6 @@ export async function fetchBeatsStates( * @return {Object} - Beats stats in an object keyed by the cluster UUIDs */ export async function getBeatsStats( - server: StatsCollectionConfig['server'], callCluster: StatsCollectionConfig['callCluster'], clusterUuids: string[], start: StatsCollectionConfig['start'], @@ -425,8 +420,8 @@ export async function getBeatsStats( }; await Promise.all([ - fetchBeatsStats(server, callCluster, clusterUuids, start, end, options), - fetchBeatsStates(server, callCluster, clusterUuids, start, end, options), + fetchBeatsStats(callCluster, clusterUuids, start, end, options), + fetchBeatsStates(callCluster, clusterUuids, start, end, options), ]); return options.clusters; diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.test.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.test.ts index 4f952b9dec6da..e8133afd95c20 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.test.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.test.ts @@ -13,17 +13,6 @@ import { describe('get_cluster_uuids', () => { const callCluster = sinon.stub(); - const size = 123; - const server = { - config: sinon.stub().returns({ - get: sinon - .stub() - .withArgs('xpack.monitoring.elasticsearch.index_pattern') - .returns('.monitoring-es-N-*') - .withArgs('xpack.monitoring.max_bucket_size') - .returns(size), - }), - }; const response = { aggregations: { cluster_uuids: { @@ -41,7 +30,9 @@ describe('get_cluster_uuids', () => { it('returns cluster UUIDs', async () => { callCluster.withArgs('search').returns(Promise.resolve(response)); expect( - await getClusterUuids({ server, callCluster, start, end, usageCollection: {} as any }) + await getClusterUuids({ callCluster, start, end, usageCollection: {} as any }, { + maxBucketSize: 1, + } as any) ).toStrictEqual(expectedUuids); }); }); @@ -50,7 +41,9 @@ describe('get_cluster_uuids', () => { it('searches for clusters', async () => { callCluster.returns(Promise.resolve(response)); expect( - await fetchClusterUuids({ server, callCluster, start, end, usageCollection: {} as any }) + await fetchClusterUuids({ callCluster, start, end, usageCollection: {} as any }, { + maxBucketSize: 1, + } as any) ).toStrictEqual(response); }); }); diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.ts index 46b3007ae80e2..db025450ba833 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.ts @@ -5,30 +5,33 @@ */ import { get } from 'lodash'; -// @ts-ignore -import { createQuery } from './create_query'; -// @ts-ignore -import { INDEX_PATTERN_ELASTICSEARCH } from '../../common/constants'; - import { ClusterDetailsGetter, StatsCollectionConfig, ClusterDetails, -} from '../../../../../src/legacy/core_plugins/telemetry/server/collection_manager'; +} from 'src/plugins/telemetry_collection_manager/server'; +import { createQuery } from './create_query'; +import { INDEX_PATTERN_ELASTICSEARCH } from '../../common/constants'; +import { CustomContext } from './get_all_stats'; /** * Get a list of Cluster UUIDs that exist within the specified timespan. */ -export const getClusterUuids: ClusterDetailsGetter = async (config: any) => { - const response = await fetchClusterUuids(config); +export const getClusterUuids: ClusterDetailsGetter<CustomContext> = async ( + config, + { maxBucketSize } +) => { + const response = await fetchClusterUuids(config, maxBucketSize); return handleClusterUuidsResponse(response); }; /** * Fetch the aggregated Cluster UUIDs from the monitoring cluster. */ -export function fetchClusterUuids({ server, callCluster, start, end }: StatsCollectionConfig) { - const config = server.config(); +export function fetchClusterUuids( + { callCluster, start, end }: StatsCollectionConfig, + maxBucketSize: number +) { const params = { index: INDEX_PATTERN_ELASTICSEARCH, size: 0, @@ -40,7 +43,7 @@ export function fetchClusterUuids({ server, callCluster, start, end }: StatsColl cluster_uuids: { terms: { field: 'cluster_uuid', - size: config.get('monitoring.ui.max_bucket_size'), + size: maxBucketSize, }, }, }, diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_es_stats.test.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_es_stats.test.ts index 70ed2240b47d4..52fb989bfd20f 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_es_stats.test.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_es_stats.test.ts @@ -13,17 +13,6 @@ import { describe('get_es_stats', () => { const callWith = sinon.stub(); - const size = 123; - const server = { - config: sinon.stub().returns({ - get: sinon - .stub() - .withArgs('xpack.monitoring.elasticsearch.index_pattern') - .returns('.monitoring-es-N-*') - .withArgs('xpack.monitoring.max_bucket_size') - .returns(size), - }), - }; const response = { hits: { hits: [ @@ -35,12 +24,13 @@ describe('get_es_stats', () => { }; const expectedClusters = response.hits.hits.map(hit => hit._source); const clusterUuids = expectedClusters.map(cluster => cluster.cluster_uuid); + const maxBucketSize = 1; describe('getElasticsearchStats', () => { it('returns clusters', async () => { callWith.withArgs('search').returns(Promise.resolve(response)); - expect(await getElasticsearchStats(server, callWith, clusterUuids)).toStrictEqual( + expect(await getElasticsearchStats(callWith, clusterUuids, maxBucketSize)).toStrictEqual( expectedClusters ); }); @@ -50,7 +40,9 @@ describe('get_es_stats', () => { it('searches for clusters', async () => { callWith.returns(response); - expect(await fetchElasticsearchStats(server, callWith, clusterUuids)).toStrictEqual(response); + expect(await fetchElasticsearchStats(callWith, clusterUuids, maxBucketSize)).toStrictEqual( + response + ); }); }); diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_es_stats.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_es_stats.ts index 2f2fffd3f0823..37b6cbcf249d6 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_es_stats.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_es_stats.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { StatsCollectionConfig } from 'src/legacy/core_plugins/telemetry/server/collection_manager'; import { SearchResponse } from 'elasticsearch'; +import { StatsCollectionConfig } from 'src/plugins/telemetry_collection_manager/server'; import { INDEX_PATTERN_ELASTICSEARCH } from '../../common/constants'; /** @@ -16,11 +16,11 @@ import { INDEX_PATTERN_ELASTICSEARCH } from '../../common/constants'; * @param {Array} clusterUuids The string Cluster UUIDs to fetch details for */ export async function getElasticsearchStats( - server: StatsCollectionConfig['server'], callCluster: StatsCollectionConfig['callCluster'], - clusterUuids: string[] + clusterUuids: string[], + maxBucketSize: number ) { - const response = await fetchElasticsearchStats(server, callCluster, clusterUuids); + const response = await fetchElasticsearchStats(callCluster, clusterUuids, maxBucketSize); return handleElasticsearchStats(response); } @@ -34,14 +34,13 @@ export async function getElasticsearchStats( * Returns the response for the aggregations to fetch details for the product. */ export function fetchElasticsearchStats( - server: StatsCollectionConfig['server'], callCluster: StatsCollectionConfig['callCluster'], - clusterUuids: string[] + clusterUuids: string[], + maxBucketSize: number ) { - const config = server.config(); const params = { index: INDEX_PATTERN_ELASTICSEARCH, - size: config.get('monitoring.ui.max_bucket_size'), + size: maxBucketSize, ignoreUnavailable: true, filterPath: [ 'hits.hits._source.cluster_uuid', diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_high_level_stats.test.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_high_level_stats.test.ts index 76c80e2eb3d37..d308ddc13e40c 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_high_level_stats.test.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_high_level_stats.test.ts @@ -13,21 +13,10 @@ import { describe('get_high_level_stats', () => { const callWith = sinon.stub(); - const size = 123; const product = 'xyz'; const cloudName = 'bare-metal'; const start = 0; const end = 1; - const server = { - config: sinon.stub().returns({ - get: sinon - .stub() - .withArgs(`xpack.monitoring.${product}.index_pattern`) - .returns(`.monitoring-${product}-N-*`) - .withArgs('xpack.monitoring.max_bucket_size') - .returns(size), - }), - }; const response = { hits: { hits: [ @@ -238,13 +227,14 @@ describe('get_high_level_stats', () => { }, }; const clusterUuids = Object.keys(expectedClusters); + const maxBucketSize = 10; describe('getHighLevelStats', () => { it('returns clusters', async () => { callWith.withArgs('search').returns(Promise.resolve(response)); expect( - await getHighLevelStats(server, callWith, clusterUuids, start, end, product) + await getHighLevelStats(callWith, clusterUuids, start, end, product, maxBucketSize) ).toStrictEqual(expectedClusters); }); }); @@ -254,7 +244,7 @@ describe('get_high_level_stats', () => { callWith.returns(Promise.resolve(response)); expect( - await fetchHighLevelStats(server, callWith, clusterUuids, start, end, product) + await fetchHighLevelStats(callWith, clusterUuids, start, end, product, maxBucketSize) ).toStrictEqual(response); }); }); diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_high_level_stats.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_high_level_stats.ts index f67f80940d9f4..87a1fb7e6e5cf 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_high_level_stats.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_high_level_stats.ts @@ -5,8 +5,8 @@ */ import { get } from 'lodash'; -import { StatsCollectionConfig } from 'src/legacy/core_plugins/telemetry/server/collection_manager'; import { SearchResponse } from 'elasticsearch'; +import { StatsCollectionConfig } from 'src/plugins/telemetry_collection_manager/server'; import { createQuery } from './create_query'; import { INDEX_PATTERN_KIBANA, @@ -247,20 +247,20 @@ function getIndexPatternForStackProduct(product: string) { * Returns an object keyed by the cluster UUIDs to make grouping easier. */ export async function getHighLevelStats( - server: StatsCollectionConfig['server'], callCluster: StatsCollectionConfig['callCluster'], clusterUuids: string[], start: StatsCollectionConfig['start'], end: StatsCollectionConfig['end'], - product: string + product: string, + maxBucketSize: number ) { const response = await fetchHighLevelStats( - server, callCluster, clusterUuids, start, end, - product + product, + maxBucketSize ); return handleHighLevelStatsResponse(response, product); } @@ -268,14 +268,13 @@ export async function getHighLevelStats( export async function fetchHighLevelStats< T extends { cluster_uuid?: string } = { cluster_uuid?: string } >( - server: StatsCollectionConfig['server'], callCluster: StatsCollectionConfig['callCluster'], clusterUuids: string[], start: StatsCollectionConfig['start'] | undefined, end: StatsCollectionConfig['end'] | undefined, - product: string + product: string, + maxBucketSize: number ): Promise<SearchResponse<T>> { - const config = server.config(); const isKibanaIndex = product === KIBANA_SYSTEM_ID; const filters: object[] = [{ terms: { cluster_uuid: clusterUuids } }]; @@ -302,7 +301,7 @@ export async function fetchHighLevelStats< const params = { index: getIndexPatternForStackProduct(product), - size: config.get('monitoring.ui.max_bucket_size'), + size: maxBucketSize, headers: { 'X-QUERY-SOURCE': TELEMETRY_QUERY_SOURCE, }, diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_kibana_stats.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_kibana_stats.ts index e2ad64ce04c6b..45df56b2139ff 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_kibana_stats.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_kibana_stats.ts @@ -6,8 +6,8 @@ import moment from 'moment'; import { isEmpty } from 'lodash'; -import { StatsCollectionConfig } from 'src/legacy/core_plugins/telemetry/server/collection_manager'; import { SearchResponse } from 'elasticsearch'; +import { StatsCollectionConfig } from 'src/plugins/telemetry_collection_manager/server'; import { KIBANA_SYSTEM_ID, TELEMETRY_COLLECTION_INTERVAL } from '../../common/constants'; import { fetchHighLevelStats, @@ -173,20 +173,20 @@ export function ensureTimeSpan( * specialized usage data that comes with kibana stats (kibana_stats.usage). */ export async function getKibanaStats( - server: StatsCollectionConfig['server'], callCluster: StatsCollectionConfig['callCluster'], clusterUuids: string[], start: StatsCollectionConfig['start'], - end: StatsCollectionConfig['end'] + end: StatsCollectionConfig['end'], + maxBucketSize: number ) { const { start: safeStart, end: safeEnd } = ensureTimeSpan(start, end); const rawStats = await fetchHighLevelStats<KibanaUsageStats>( - server, callCluster, clusterUuids, safeStart, safeEnd, - KIBANA_SYSTEM_ID + KIBANA_SYSTEM_ID, + maxBucketSize ); const highLevelStats = handleHighLevelStatsResponse(rawStats, KIBANA_SYSTEM_ID); const usageStats = getUsageStats(rawStats); diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_licenses.test.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_licenses.test.ts index bb8326ce0b63a..16fa47afdb692 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_licenses.test.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_licenses.test.ts @@ -9,17 +9,6 @@ import { getLicenses, handleLicenses, fetchLicenses } from './get_licenses'; describe('get_licenses', () => { const callWith = sinon.stub(); - const size = 123; - const server = { - config: sinon.stub().returns({ - get: sinon - .stub() - .withArgs('xpack.monitoring.elasticsearch.index_pattern') - .returns('.monitoring-es-N-*') - .withArgs('xpack.monitoring.max_bucket_size') - .returns(size), - }), - }; const response = { hits: { hits: [ @@ -42,7 +31,11 @@ describe('get_licenses', () => { callWith.withArgs('search').returns(Promise.resolve(response)); expect( - await getLicenses(clusterUuids, { server, callCluster: callWith } as any) + await getLicenses( + clusterUuids, + { callCluster: callWith } as any, + { maxBucketSize: 1 } as any + ) ).toStrictEqual(expectedLicenses); }); }); @@ -53,9 +46,9 @@ describe('get_licenses', () => { expect( await fetchLicenses( - server, callWith, - clusterUuids.map(({ clusterUuid }) => clusterUuid) + clusterUuids.map(({ clusterUuid }) => clusterUuid), + { maxBucketSize: 1 } as any ) ).toStrictEqual(response); }); diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_licenses.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_licenses.ts index 7364227e7dc92..0d41ac0f46814 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_licenses.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_licenses.ts @@ -4,20 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ +import { SearchResponse } from 'elasticsearch'; import { - StatsCollectionConfig, + ESLicense, LicenseGetter, -} from 'src/legacy/core_plugins/telemetry/server/collection_manager'; -import { SearchResponse } from 'elasticsearch'; -import { ESLicense } from 'src/legacy/core_plugins/telemetry/server/telemetry_collection/get_local_license'; + StatsCollectionConfig, +} from 'src/plugins/telemetry_collection_manager/server'; import { INDEX_PATTERN_ELASTICSEARCH } from '../../common/constants'; +import { CustomContext } from './get_all_stats'; /** * Get statistics for all selected Elasticsearch clusters. */ -export const getLicenses: LicenseGetter = async (clustersDetails, { server, callCluster }) => { +export const getLicenses: LicenseGetter<CustomContext> = async ( + clustersDetails, + { callCluster }, + { maxBucketSize } +) => { const clusterUuids = clustersDetails.map(({ clusterUuid }) => clusterUuid); - const response = await fetchLicenses(server, callCluster, clusterUuids); + const response = await fetchLicenses(callCluster, clusterUuids, maxBucketSize); return handleLicenses(response); }; @@ -31,14 +36,13 @@ export const getLicenses: LicenseGetter = async (clustersDetails, { server, call * Returns the response for the aggregations to fetch details for the product. */ export function fetchLicenses( - server: StatsCollectionConfig['server'], callCluster: StatsCollectionConfig['callCluster'], - clusterUuids: string[] + clusterUuids: string[], + maxBucketSize: number ) { - const config = server.config(); const params = { index: INDEX_PATTERN_ELASTICSEARCH, - size: config.get('monitoring.ui.max_bucket_size'), + size: maxBucketSize, ignoreUnavailable: true, filterPath: ['hits.hits._source.cluster_uuid', 'hits.hits._source.license'], body: { diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_collection.ts b/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_collection.ts index 7bc4e9cbf7558..d81f549de7522 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_collection.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_collection.ts @@ -5,29 +5,23 @@ */ import { ICustomClusterClient } from 'kibana/server'; -import { Cluster } from 'src/legacy/core_plugins/elasticsearch'; -// @ts-ignore -import { getAllStats } from './get_all_stats'; +import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; +import { getAllStats, CustomContext } from './get_all_stats'; import { getClusterUuids } from './get_cluster_uuids'; import { getLicenses } from './get_licenses'; export function registerMonitoringCollection( - cluster: ICustomClusterClient, - telemetryCollectionManager: any + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup, + esCluster: ICustomClusterClient, + customContext: CustomContext ) { - // Create a legacy wrapper since telemetry is still in the legacy plugins - const legacyCluster: Cluster = { - callWithRequest: async (req: any, endpoint: string, params: any) => - cluster.asScoped(req).callAsCurrentUser(endpoint, params), - callWithInternalUser: (endpoint: string, params: any) => - cluster.callAsInternalUser(endpoint, params), - }; telemetryCollectionManager.setCollection({ - esCluster: legacyCluster, + esCluster, title: 'monitoring', priority: 2, statsGetter: getAllStats, clusterDetailsGetter: getClusterUuids, licenseGetter: getLicenses, + customContext, }); } diff --git a/x-pack/plugins/telemetry_collection_xpack/README.md b/x-pack/plugins/telemetry_collection_xpack/README.md new file mode 100644 index 0000000000000..6c205bb17c663 --- /dev/null +++ b/x-pack/plugins/telemetry_collection_xpack/README.md @@ -0,0 +1,9 @@ +# Telemetry collection with X-Pack + +Gathers all usage collection, retrieving them from both: OSS and X-Pack plugins. + +--- + +## Development + +See the [kibana contributing guide](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md) for instructions on how to set up your development environment. diff --git a/x-pack/plugins/telemetry_collection_xpack/common/index.ts b/x-pack/plugins/telemetry_collection_xpack/common/index.ts new file mode 100644 index 0000000000000..2b08ebe2e7bbf --- /dev/null +++ b/x-pack/plugins/telemetry_collection_xpack/common/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const PLUGIN_ID = 'telemetryCollectionXpack'; +export const PLUGIN_NAME = 'telemetry_collection_xpack'; diff --git a/x-pack/plugins/telemetry_collection_xpack/kibana.json b/x-pack/plugins/telemetry_collection_xpack/kibana.json new file mode 100644 index 0000000000000..8499e0258f8ff --- /dev/null +++ b/x-pack/plugins/telemetry_collection_xpack/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "telemetryCollectionXpack", + "version": "kibana", + "server": true, + "ui": false, + "requiredPlugins": [ + "telemetryCollectionManager" + ], + "optionalPlugins": [] +} diff --git a/x-pack/plugins/telemetry_collection_xpack/server/index.ts b/x-pack/plugins/telemetry_collection_xpack/server/index.ts new file mode 100644 index 0000000000000..249d16c331c39 --- /dev/null +++ b/x-pack/plugins/telemetry_collection_xpack/server/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'kibana/server'; +import { TelemetryCollectionXpackPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. + +export function plugin(initializerContext: PluginInitializerContext) { + return new TelemetryCollectionXpackPlugin(initializerContext); +} diff --git a/x-pack/plugins/telemetry_collection_xpack/server/plugin.ts b/x-pack/plugins/telemetry_collection_xpack/server/plugin.ts new file mode 100644 index 0000000000000..b0afba8852495 --- /dev/null +++ b/x-pack/plugins/telemetry_collection_xpack/server/plugin.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'kibana/server'; +import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; +import { getClusterUuids, getLocalLicense } from '../../../../src/plugins/telemetry/server'; +import { getStatsWithXpack } from './telemetry_collection'; + +interface TelemetryCollectionXpackDepsSetup { + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup; +} + +export class TelemetryCollectionXpackPlugin implements Plugin { + constructor(initializerContext: PluginInitializerContext) {} + + public setup(core: CoreSetup, { telemetryCollectionManager }: TelemetryCollectionXpackDepsSetup) { + telemetryCollectionManager.setCollection({ + esCluster: core.elasticsearch.dataClient, + title: 'local_xpack', + priority: 1, + statsGetter: getStatsWithXpack, + clusterDetailsGetter: getClusterUuids, + licenseGetter: getLocalLicense, + }); + } + + public start(core: CoreStart) {} +} diff --git a/x-pack/legacy/plugins/xpack_main/server/telemetry_collection/__snapshots__/get_stats_with_xpack.test.ts.snap b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/__snapshots__/get_stats_with_xpack.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/xpack_main/server/telemetry_collection/__snapshots__/get_stats_with_xpack.test.ts.snap rename to x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/__snapshots__/get_stats_with_xpack.test.ts.snap diff --git a/x-pack/legacy/plugins/xpack_main/server/telemetry_collection/__tests__/get_xpack.js b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/__tests__/get_xpack.js similarity index 100% rename from x-pack/legacy/plugins/xpack_main/server/telemetry_collection/__tests__/get_xpack.js rename to x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/__tests__/get_xpack.js diff --git a/x-pack/legacy/plugins/xpack_main/server/telemetry_collection/constants.ts b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/constants.ts similarity index 100% rename from x-pack/legacy/plugins/xpack_main/server/telemetry_collection/constants.ts rename to x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/constants.ts diff --git a/x-pack/legacy/plugins/xpack_main/server/telemetry_collection/get_stats_with_xpack.test.ts b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.test.ts similarity index 73% rename from x-pack/legacy/plugins/xpack_main/server/telemetry_collection/get_stats_with_xpack.test.ts rename to x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.test.ts index b85cbd9661022..f2a9995098e59 100644 --- a/x-pack/legacy/plugins/xpack_main/server/telemetry_collection/get_stats_with_xpack.test.ts +++ b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.test.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { coreMock } from '../../../../../src/core/server/mocks'; import { getStatsWithXpack } from './get_stats_with_xpack'; const kibana = { @@ -28,26 +29,10 @@ const kibana = { snow: { chances: 0 }, }; -const getMockServer = (getCluster = jest.fn()) => ({ - log(tags: string[], message: string) { - // eslint-disable-next-line no-console - console.log({ tags, message }); - }, - config() { - return { - get(item: string) { - switch (item) { - case 'pkg.version': - return '8675309-snapshot'; - default: - throw Error(`unexpected config.get('${item}') received.`); - } - }, - }; - }, - plugins: { - elasticsearch: { getCluster }, - }, +const getContext = () => ({ + version: '8675309-snapshot', + isDev: true, + logger: coreMock.createPluginInitializerContext().logger.get('test'), }); const mockUsageCollection = (kibanaUsage = kibana) => ({ @@ -72,13 +57,16 @@ describe('Telemetry Collection: Get Aggregated Stats', () => { } }); const usageCollection = mockUsageCollection(); - const server = getMockServer(); + const context = getContext(); - const stats = await getStatsWithXpack([{ clusterUuid: '1234' }], { - callCluster, - usageCollection, - server, - } as any); + const stats = await getStatsWithXpack( + [{ clusterUuid: '1234' }], + { + callCluster, + usageCollection, + } as any, + context + ); expect(stats.map(({ timestamp, ...rest }) => rest)).toMatchSnapshot(); }); @@ -101,13 +89,16 @@ describe('Telemetry Collection: Get Aggregated Stats', () => { } }); const usageCollection = mockUsageCollection(); - const server = getMockServer(); + const context = getContext(); - const stats = await getStatsWithXpack([{ clusterUuid: '1234' }], { - callCluster, - usageCollection, - server, - } as any); + const stats = await getStatsWithXpack( + [{ clusterUuid: '1234' }], + { + callCluster, + usageCollection, + } as any, + context + ); expect(stats.map(({ timestamp, ...rest }) => rest)).toMatchSnapshot(); }); }); diff --git a/x-pack/legacy/plugins/xpack_main/server/telemetry_collection/get_stats_with_xpack.ts b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.ts similarity index 70% rename from x-pack/legacy/plugins/xpack_main/server/telemetry_collection/get_stats_with_xpack.ts rename to x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.ts index ea7465f66f120..705b2a11bde5b 100644 --- a/x-pack/legacy/plugins/xpack_main/server/telemetry_collection/get_stats_with_xpack.ts +++ b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_stats_with_xpack.ts @@ -4,23 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { StatsGetter } from '../../../../../../src/legacy/core_plugins/telemetry/server/collection_manager'; -import { - getLocalStats, - TelemetryLocalStats, -} from '../../../../../../src/legacy/core_plugins/telemetry/server/telemetry_collection/get_local_stats'; +import { TelemetryLocalStats, getLocalStats } from '../../../../../src/plugins/telemetry/server'; +import { StatsGetter } from '../../../../../src/plugins/telemetry_collection_manager/server'; import { getXPackUsage } from './get_xpack'; export type TelemetryAggregatedStats = TelemetryLocalStats & { stack_stats: { xpack?: object }; }; -export const getStatsWithXpack: StatsGetter<TelemetryAggregatedStats> = async function( +export const getStatsWithXpack: StatsGetter<{}, TelemetryAggregatedStats> = async function( clustersDetails, - config + config, + context ) { const { callCluster } = config; - const clustersLocalStats = await getLocalStats(clustersDetails, config); + const clustersLocalStats = await getLocalStats(clustersDetails, config, context); const xpack = await getXPackUsage(callCluster).catch(() => undefined); // We want to still report something (and do not lose the license) even when this method fails. return clustersLocalStats.map(localStats => { diff --git a/x-pack/legacy/plugins/xpack_main/server/telemetry_collection/get_xpack.ts b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_xpack.ts similarity index 100% rename from x-pack/legacy/plugins/xpack_main/server/telemetry_collection/get_xpack.ts rename to x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/get_xpack.ts diff --git a/x-pack/legacy/plugins/xpack_main/server/telemetry_collection/index.ts b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/index.ts similarity index 76% rename from x-pack/legacy/plugins/xpack_main/server/telemetry_collection/index.ts rename to x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/index.ts index 0b13957d7089c..553f8dc0c4188 100644 --- a/x-pack/legacy/plugins/xpack_main/server/telemetry_collection/index.ts +++ b/x-pack/plugins/telemetry_collection_xpack/server/telemetry_collection/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { registerMonitoringCollection } from './register_xpack_collection'; +export { getStatsWithXpack } from './get_stats_with_xpack'; diff --git a/x-pack/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts b/x-pack/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts index 582864795015c..c25ea403ce364 100644 --- a/x-pack/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts +++ b/x-pack/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import { Client, DeleteDocumentParams, GetParams, GetResponse } from 'elasticsearch'; -import { TelemetrySavedObjectAttributes } from '../../../../../src/legacy/core_plugins/telemetry/server/telemetry_repository'; +import { TelemetrySavedObjectAttributes } from '../../../../../src/plugins/telemetry/server/telemetry_repository'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function optInTest({ getService }: FtrProviderContext) { From 2da5d635bf810b926176e48e1e5e23a26a3dbb5b Mon Sep 17 00:00:00 2001 From: Poff Poffenberger <poffdeluxe@gmail.com> Date: Mon, 23 Mar 2020 14:09:05 -0500 Subject: [PATCH 031/179] Re-enable a CSS fix for the Monaco Editor's focus behavior (#60803) Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- src/plugins/kibana_react/public/code_editor/code_editor.tsx | 2 ++ src/plugins/kibana_react/public/code_editor/editor.scss | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugins/kibana_react/public/code_editor/code_editor.tsx b/src/plugins/kibana_react/public/code_editor/code_editor.tsx index 37707fdec2140..e8b118b804347 100644 --- a/src/plugins/kibana_react/public/code_editor/code_editor.tsx +++ b/src/plugins/kibana_react/public/code_editor/code_editor.tsx @@ -25,6 +25,8 @@ import { monaco } from '@kbn/ui-shared-deps/monaco'; import { LIGHT_THEME, DARK_THEME } from './editor_theme'; +import './editor.scss'; + export interface Props { /** Width of editor. Defaults to 100%. */ width?: string | number; diff --git a/src/plugins/kibana_react/public/code_editor/editor.scss b/src/plugins/kibana_react/public/code_editor/editor.scss index 86a764716bc7c..23a3e3af7e656 100644 --- a/src/plugins/kibana_react/public/code_editor/editor.scss +++ b/src/plugins/kibana_react/public/code_editor/editor.scss @@ -1,3 +1,3 @@ .react-monaco-editor-container .monaco-editor .inputarea:focus { - animation: none; // Removes textarea EUI blue underline animation from EUI + animation: none !important; // Removes textarea EUI blue underline animation from EUI } From 65359856a0db4671558b10e5ee0fdc560485f4d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= <sorenlouv@gmail.com> Date: Mon, 23 Mar 2020 20:14:26 +0100 Subject: [PATCH 032/179] [APM] Remote Agent Config: Add additional (java) options (#59860) --- .../app/Main/route_config/index.tsx | 29 +- .../route_handlers/agent_configuration.tsx | 56 ++++ .../app/Main/route_config/route_names.tsx | 2 + .../AddEditFlyout/DeleteButton.tsx | 96 ------ .../AddEditFlyout/ServiceSection.tsx | 159 --------- .../AddEditFlyout/SettingsSection.tsx | 174 ---------- .../AddEditFlyout/index.tsx | 256 --------------- .../AddEditFlyout/saveConfig.ts | 107 ------- .../ServicePage/FormRowSelect.tsx | 53 +++ .../ServicePage/ServicePage.tsx | 208 ++++++++++++ .../SettingsPage/SettingFormRow.tsx | 178 +++++++++++ .../SettingsPage/SettingsPage.tsx | 301 ++++++++++++++++++ .../SettingsPage/saveConfig.ts | 68 ++++ .../index.stories.tsx | 63 ++++ .../AgentConfigurationCreateEdit/index.tsx | 157 +++++++++ .../AgentConfigurationList.tsx | 232 -------------- .../List/ConfirmDeleteModal.tsx | 119 +++++++ .../AgentConfigurations/List/index.tsx | 221 +++++++++++++ .../Settings/AgentConfigurations/index.tsx | 66 +--- .../public/components/app/Settings/index.tsx | 16 +- .../components/shared/Links/apm/APMLink.tsx | 2 +- .../Links/apm/agentConfigurationLinks.tsx | 26 ++ .../shared/SelectWithPlaceholder/index.tsx | 47 +-- .../apm/public/context/LocationContext.tsx | 8 +- .../plugins/apm/public/hooks/useFetcher.tsx | 39 ++- .../all_option.ts} | 6 +- .../agent_configuration/amount_and_unit.ts | 19 ++ .../configuration_types.d.ts | 3 +- .../agent_configuration_intake_rt.test.ts | 58 ++++ .../agent_configuration_intake_rt.ts | 35 ++ .../runtime_types/boolean_rt.test.ts | 26 ++ .../runtime_types/boolean_rt.ts | 9 + .../runtime_types/bytes_rt.test.ts | 39 +++ .../runtime_types/bytes_rt.ts | 33 ++ .../runtime_types/capture_body_rt.test.ts | 26 ++ .../runtime_types/capture_body_rt.ts | 14 + .../runtime_types/duration_rt.test.ts | 34 ++ .../runtime_types/duration_rt.ts | 33 ++ .../runtime_types/integer_rt.test.ts | 47 +++ .../runtime_types/integer_rt.ts | 31 ++ .../runtime_types/number_float_rt.test.ts | 36 +++ .../runtime_types/number_float_rt.ts | 36 +++ .../__snapshots__/index.test.ts.snap | 191 +++++++++++ .../setting_definitions/general_settings.ts | 219 +++++++++++++ .../setting_definitions/index.test.ts | 187 +++++++++++ .../setting_definitions/index.ts | 113 +++++++ .../setting_definitions/java_settings.ts | 256 +++++++++++++++ .../setting_definitions/types.d.ts | 107 +++++++ .../index.test.ts | 41 --- .../agent_configuration_intake_rt/index.ts | 26 -- .../transaction_max_spans_rt/index.test.ts | 28 -- .../transaction_max_spans_rt/index.ts | 19 -- .../transaction_sample_rate_rt/index.test.ts | 38 --- .../transaction_sample_rate_rt/index.ts | 19 -- .../lib/helpers/create_or_update_index.ts | 3 +- .../convert_settings_to_string.ts | 26 ++ .../create_agent_config_index.ts | 31 +- .../create_or_update_configuration.ts | 2 +- .../find_exact_configuration.ts | 11 +- .../get_environments/get_all_environments.ts | 2 +- .../get_existing_environments_for_service.ts | 2 +- .../agent_configuration/get_service_names.ts | 2 +- .../list_configurations.ts | 10 +- .../mark_applied_by_agent.ts | 2 +- .../search_configurations.ts | 11 +- .../custom_link/create_custom_link_index.ts | 1 + .../apm/server/routes/create_apm_api.ts | 2 + .../routes/settings/agent_configuration.ts | 118 ++++--- .../translations/translations/ja-JP.json | 42 --- .../translations/translations/zh-CN.json | 42 --- .../apis/apm/agent_configuration.ts | 54 ++-- .../apis/apm/feature_controls.ts | 2 +- 72 files changed, 3290 insertions(+), 1485 deletions(-) create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/SettingsSection.tsx delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/index.tsx delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/saveConfig.ts create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/FormRowSelect.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationList.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx rename x-pack/plugins/apm/common/{agent_configuration_constants.ts => agent_configuration/all_option.ts} (74%) create mode 100644 x-pack/plugins/apm/common/agent_configuration/amount_and_unit.ts rename x-pack/plugins/apm/{server/lib/settings => common}/agent_configuration/configuration_types.d.ts (82%) create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.test.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/boolean_rt.test.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/boolean_rt.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.test.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/capture_body_rt.test.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/capture_body_rt.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.test.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.test.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.test.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap create mode 100644 x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/setting_definitions/java_settings.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts delete mode 100644 x-pack/plugins/apm/common/runtime_types/agent_configuration_intake_rt/index.test.ts delete mode 100644 x-pack/plugins/apm/common/runtime_types/agent_configuration_intake_rt/index.ts delete mode 100644 x-pack/plugins/apm/common/runtime_types/transaction_max_spans_rt/index.test.ts delete mode 100644 x-pack/plugins/apm/common/runtime_types/transaction_max_spans_rt/index.ts delete mode 100644 x-pack/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.test.ts delete mode 100644 x-pack/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.ts create mode 100644 x-pack/plugins/apm/server/lib/settings/agent_configuration/convert_settings_to_string.ts diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx index 2e737382c67a5..c87e56fe9eff6 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx @@ -23,6 +23,10 @@ import { resolveUrlParams } from '../../../../context/UrlParamsContext/resolveUr import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../../../../../plugins/apm/common/i18n'; import { TraceLink } from '../../TraceLink'; import { CustomizeUI } from '../../Settings/CustomizeUI'; +import { + EditAgentConfigurationRouteHandler, + CreateAgentConfigurationRouteHandler +} from './route_handlers/agent_configuration'; const metricsBreadcrumb = i18n.translate('xpack.apm.breadcrumb.metricsTitle', { defaultMessage: 'Metrics' @@ -101,12 +105,31 @@ export const routes: BreadcrumbRoute[] = [ ), breadcrumb: i18n.translate( 'xpack.apm.breadcrumb.settings.agentConfigurationTitle', - { - defaultMessage: 'Agent Configuration' - } + { defaultMessage: 'Agent Configuration' } ), name: RouteName.AGENT_CONFIGURATION }, + + { + exact: true, + path: '/settings/agent-configuration/create', + breadcrumb: i18n.translate( + 'xpack.apm.breadcrumb.settings.createAgentConfigurationTitle', + { defaultMessage: 'Create Agent Configuration' } + ), + name: RouteName.AGENT_CONFIGURATION_CREATE, + component: () => <CreateAgentConfigurationRouteHandler /> + }, + { + exact: true, + path: '/settings/agent-configuration/edit', + breadcrumb: i18n.translate( + 'xpack.apm.breadcrumb.settings.editAgentConfigurationTitle', + { defaultMessage: 'Edit Agent Configuration' } + ), + name: RouteName.AGENT_CONFIGURATION_EDIT, + component: () => <EditAgentConfigurationRouteHandler /> + }, { exact: true, path: '/services/:serviceName', diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx new file mode 100644 index 0000000000000..58087f0d8be42 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx @@ -0,0 +1,56 @@ +/* + * 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 from 'react'; +import { useFetcher } from '../../../../../hooks/useFetcher'; +import { history } from '../../../../../utils/history'; +import { Settings } from '../../../Settings'; +import { AgentConfigurationCreateEdit } from '../../../Settings/AgentConfigurations/AgentConfigurationCreateEdit'; +import { toQuery } from '../../../../shared/Links/url_helpers'; + +export function EditAgentConfigurationRouteHandler() { + const { search } = history.location; + + // typescript complains because `pageStop` does not exist in `APMQueryParams` + // Going forward we should move away from globally declared query params and this is a first step + // @ts-ignore + const { name, environment, pageStep } = toQuery(search); + + const res = useFetcher( + callApmApi => { + return callApmApi({ + pathname: '/api/apm/settings/agent-configuration/view', + params: { query: { name, environment } } + }); + }, + [name, environment] + ); + + return ( + <Settings> + <AgentConfigurationCreateEdit + pageStep={pageStep || 'choose-settings-step'} + existingConfigResult={res} + /> + </Settings> + ); +} + +export function CreateAgentConfigurationRouteHandler() { + const { search } = history.location; + + // Ignoring here because we specifically DO NOT want to add the query params to the global route handler + // @ts-ignore + const { pageStep } = toQuery(search); + + return ( + <Settings> + <AgentConfigurationCreateEdit + pageStep={pageStep || 'choose-service-step'} + /> + </Settings> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx index db57e8356f39b..33a4990cb549e 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx @@ -20,6 +20,8 @@ export enum RouteName { TRANSACTION_NAME = 'transaction_name', SETTINGS = 'settings', AGENT_CONFIGURATION = 'agent_configuration', + AGENT_CONFIGURATION_CREATE = 'agent_configuration_create', + AGENT_CONFIGURATION_EDIT = 'agent_configuration_edit', INDICES = 'indices', SERVICE_NODES = 'nodes', LINK_TO_TRACE = 'link_to_trace', diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx deleted file mode 100644 index 997df371b51ed..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx +++ /dev/null @@ -1,96 +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 React, { useState } from 'react'; -import { EuiButtonEmpty } from '@elastic/eui'; -import { NotificationsStart } from 'kibana/public'; -import { i18n } from '@kbn/i18n'; -import { Config } from '../index'; -import { getOptionLabel } from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { callApmApi } from '../../../../../services/rest/createCallApmApi'; -import { useApmPluginContext } from '../../../../../hooks/useApmPluginContext'; - -interface Props { - onDeleted: () => void; - selectedConfig: Config; -} - -export function DeleteButton({ onDeleted, selectedConfig }: Props) { - const [isDeleting, setIsDeleting] = useState(false); - const { toasts } = useApmPluginContext().core.notifications; - - return ( - <EuiButtonEmpty - color="danger" - isLoading={isDeleting} - iconSide="right" - onClick={async () => { - setIsDeleting(true); - await deleteConfig(selectedConfig, toasts); - setIsDeleting(false); - onDeleted(); - }} - > - {i18n.translate( - 'xpack.apm.settings.agentConf.flyout.deleteSection.buttonLabel', - { defaultMessage: 'Delete' } - )} - </EuiButtonEmpty> - ); -} - -async function deleteConfig( - selectedConfig: Config, - toasts: NotificationsStart['toasts'] -) { - try { - await callApmApi({ - pathname: '/api/apm/settings/agent-configuration', - method: 'DELETE', - params: { - body: { - service: { - name: selectedConfig.service.name, - environment: selectedConfig.service.environment - } - } - } - }); - - toasts.addSuccess({ - title: i18n.translate( - 'xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigSucceededTitle', - { defaultMessage: 'Configuration was deleted' } - ), - text: i18n.translate( - 'xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigSucceededText', - { - defaultMessage: - 'You have successfully deleted a configuration for "{serviceName}". It will take some time to propagate to the agents.', - values: { serviceName: getOptionLabel(selectedConfig.service.name) } - } - ) - }); - } catch (error) { - toasts.addDanger({ - title: i18n.translate( - 'xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigFailedTitle', - { defaultMessage: 'Configuration could not be deleted' } - ), - text: i18n.translate( - 'xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigFailedText', - { - defaultMessage: - 'Something went wrong when deleting a configuration for "{serviceName}". Error: "{errorMessage}"', - values: { - serviceName: getOptionLabel(selectedConfig.service.name), - errorMessage: error.message - } - } - ) - }); - } -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx deleted file mode 100644 index 537bdace50e24..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx +++ /dev/null @@ -1,159 +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 { EuiTitle, EuiSpacer, EuiFormRow, EuiText } from '@elastic/eui'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { - omitAllOption, - getOptionLabel -} from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { useFetcher } from '../../../../../hooks/useFetcher'; -import { SelectWithPlaceholder } from '../../../../shared/SelectWithPlaceholder'; - -const SELECT_PLACEHOLDER_LABEL = `- ${i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceSection.selectPlaceholder', - { defaultMessage: 'Select' } -)} -`; - -interface Props { - isReadOnly: boolean; - serviceName: string; - onServiceNameChange: (env: string) => void; - environment: string; - onEnvironmentChange: (env: string) => void; -} - -export function ServiceSection({ - isReadOnly, - serviceName, - onServiceNameChange, - environment, - onEnvironmentChange -}: Props) { - const { data: serviceNames = [], status: serviceNamesStatus } = useFetcher( - callApmApi => { - if (!isReadOnly) { - return callApmApi({ - pathname: '/api/apm/settings/agent-configuration/services', - forceCache: true - }); - } - }, - [isReadOnly], - { preservePreviousData: false } - ); - const { data: environments = [], status: environmentStatus } = useFetcher( - callApmApi => { - if (!isReadOnly && serviceName) { - return callApmApi({ - pathname: '/api/apm/settings/agent-configuration/environments', - params: { query: { serviceName: omitAllOption(serviceName) } } - }); - } - }, - [isReadOnly, serviceName], - { preservePreviousData: false } - ); - - const ALREADY_CONFIGURED_TRANSLATED = i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceSection.alreadyConfiguredOption', - { defaultMessage: 'already configured' } - ); - - const serviceNameOptions = serviceNames.map(name => ({ - text: getOptionLabel(name), - value: name - })); - const environmentOptions = environments.map( - ({ name, alreadyConfigured }) => ({ - disabled: alreadyConfigured, - text: `${getOptionLabel(name)} ${ - alreadyConfigured ? `(${ALREADY_CONFIGURED_TRANSLATED})` : '' - }`, - value: name - }) - ); - - return ( - <> - <EuiTitle size="xs"> - <h3> - {i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceSection.title', - { defaultMessage: 'Service' } - )} - </h3> - </EuiTitle> - - <EuiSpacer size="m" /> - - <EuiFormRow - label={i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceSection.serviceNameSelectLabel', - { defaultMessage: 'Name' } - )} - helpText={ - !isReadOnly && - i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceSection.serviceNameSelectHelpText', - { defaultMessage: 'Choose the service you want to configure.' } - ) - } - > - {isReadOnly ? ( - <EuiText>{getOptionLabel(serviceName)}</EuiText> - ) : ( - <SelectWithPlaceholder - placeholder={SELECT_PLACEHOLDER_LABEL} - isLoading={serviceNamesStatus === 'loading'} - options={serviceNameOptions} - value={serviceName} - disabled={serviceNamesStatus === 'loading'} - onChange={e => { - e.preventDefault(); - onServiceNameChange(e.target.value); - onEnvironmentChange(''); - }} - /> - )} - </EuiFormRow> - - <EuiFormRow - label={i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceSection.serviceEnvironmentSelectLabel', - { defaultMessage: 'Environment' } - )} - helpText={ - !isReadOnly && - i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceSection.serviceEnvironmentSelectHelpText', - { - defaultMessage: - 'Only a single environment per configuration is supported.' - } - ) - } - > - {isReadOnly ? ( - <EuiText>{getOptionLabel(environment)}</EuiText> - ) : ( - <SelectWithPlaceholder - placeholder={SELECT_PLACEHOLDER_LABEL} - isLoading={environmentStatus === 'loading'} - options={environmentOptions} - value={environment} - disabled={!serviceName || environmentStatus === 'loading'} - onChange={e => { - e.preventDefault(); - onEnvironmentChange(e.target.value); - }} - /> - )} - </EuiFormRow> - </> - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/SettingsSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/SettingsSection.tsx deleted file mode 100644 index 24c8222d4cd99..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/SettingsSection.tsx +++ /dev/null @@ -1,174 +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 React from 'react'; -import { - EuiFormRow, - EuiFieldText, - EuiTitle, - EuiSpacer, - EuiFieldNumber -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { isEmpty } from 'lodash'; -import { SelectWithPlaceholder } from '../../../../shared/SelectWithPlaceholder'; - -interface Props { - isRumService: boolean; - - // sampleRate - sampleRate: string; - setSampleRate: (value: string) => void; - isSampleRateValid?: boolean; - - // captureBody - captureBody: string; - setCaptureBody: (value: string) => void; - - // transactionMaxSpans - transactionMaxSpans: string; - setTransactionMaxSpans: (value: string) => void; - isTransactionMaxSpansValid?: boolean; -} - -export function SettingsSection({ - isRumService, - - // sampleRate - sampleRate, - setSampleRate, - isSampleRateValid, - - // captureBody - captureBody, - setCaptureBody, - - // transactionMaxSpans - transactionMaxSpans, - setTransactionMaxSpans, - isTransactionMaxSpansValid -}: Props) { - return ( - <> - <EuiTitle size="xs"> - <h3> - {i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.settingsSection.title', - { defaultMessage: 'Options' } - )} - </h3> - </EuiTitle> - - <EuiSpacer size="m" /> - - <EuiFormRow - label={i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputLabel', - { defaultMessage: 'Transaction sample rate' } - )} - helpText={i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputHelpText', - { - defaultMessage: - 'Choose a rate between 0.000 and 1.0. Default is 1.0 (100% of traces).' - } - )} - error={i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputErrorText', - { defaultMessage: 'Sample rate must be between 0.000 and 1' } - )} - isInvalid={!isEmpty(sampleRate) && !isSampleRateValid} - > - <EuiFieldText - placeholder={i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputPlaceholderText', - { defaultMessage: 'Set sample rate' } - )} - value={sampleRate} - onChange={e => { - e.preventDefault(); - setSampleRate(e.target.value); - }} - /> - </EuiFormRow> - - <EuiSpacer size="m" /> - - {!isRumService && ( - <EuiFormRow - label={i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputLabel', - { defaultMessage: 'Capture body' } - )} - helpText={i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputHelpText', - { - defaultMessage: - 'For transactions that are HTTP requests, the agent can optionally capture the request body (e.g. POST variables). Default is "off".' - } - )} - > - <SelectWithPlaceholder - placeholder={i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputPlaceholderText', - { defaultMessage: 'Select option' } - )} - options={[ - { text: 'off' }, - { text: 'errors' }, - { text: 'transactions' }, - { text: 'all' } - ]} - value={captureBody} - onChange={e => { - e.preventDefault(); - setCaptureBody(e.target.value); - }} - /> - </EuiFormRow> - )} - - {!isRumService && ( - <EuiFormRow - label={i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputLabel', - { defaultMessage: 'Transaction max spans' } - )} - helpText={i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputHelpText', - { - defaultMessage: - 'Limits the amount of spans that are recorded per transaction. Default is 500.' - } - )} - error={i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputErrorText', - { defaultMessage: 'Must be between 0 and 32000' } - )} - isInvalid={ - !isEmpty(transactionMaxSpans) && !isTransactionMaxSpansValid - } - > - <EuiFieldNumber - placeholder={i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputPlaceholderText', - { defaultMessage: 'Set transaction max spans' } - )} - value={ - transactionMaxSpans === '' ? '' : Number(transactionMaxSpans) - } - min={0} - max={32000} - onChange={e => { - e.preventDefault(); - setTransactionMaxSpans(e.target.value); - }} - /> - </EuiFormRow> - )} - </> - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/index.tsx deleted file mode 100644 index a034ca543390f..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/index.tsx +++ /dev/null @@ -1,256 +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 { - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiForm, - EuiPortal, - EuiTitle, - EuiText, - EuiSpacer -} from '@elastic/eui'; -import React, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { isRight } from 'fp-ts/lib/Either'; -import { transactionSampleRateRt } from '../../../../../../../../../plugins/apm/common/runtime_types/transaction_sample_rate_rt'; -import { Config } from '../index'; -import { SettingsSection } from './SettingsSection'; -import { ServiceSection } from './ServiceSection'; -import { DeleteButton } from './DeleteButton'; -import { transactionMaxSpansRt } from '../../../../../../../../../plugins/apm/common/runtime_types/transaction_max_spans_rt'; -import { useFetcher } from '../../../../../hooks/useFetcher'; -import { isRumAgentName } from '../../../../../../../../../plugins/apm/common/agent_name'; -import { ALL_OPTION_VALUE } from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { saveConfig } from './saveConfig'; -import { useApmPluginContext } from '../../../../../hooks/useApmPluginContext'; -import { useUiTracker } from '../../../../../../../../../plugins/observability/public'; - -const defaultSettings = { - TRANSACTION_SAMPLE_RATE: '1.0', - CAPTURE_BODY: 'off', - TRANSACTION_MAX_SPANS: '500' -}; - -interface Props { - onClose: () => void; - onSaved: () => void; - onDeleted: () => void; - selectedConfig: Config | null; -} - -export function AddEditFlyout({ - onClose, - onSaved, - onDeleted, - selectedConfig -}: Props) { - const { toasts } = useApmPluginContext().core.notifications; - const [isSaving, setIsSaving] = useState(false); - - // get a telemetry UI event tracker - const trackApmEvent = useUiTracker({ app: 'apm' }); - - // config conditions (service) - const [serviceName, setServiceName] = useState<string>( - selectedConfig ? selectedConfig.service.name || ALL_OPTION_VALUE : '' - ); - const [environment, setEnvironment] = useState<string>( - selectedConfig ? selectedConfig.service.environment || ALL_OPTION_VALUE : '' - ); - - const { data: { agentName } = { agentName: undefined } } = useFetcher( - callApmApi => { - if (serviceName === ALL_OPTION_VALUE) { - return Promise.resolve({ agentName: undefined }); - } - - if (serviceName) { - return callApmApi({ - pathname: '/api/apm/settings/agent-configuration/agent_name', - params: { query: { serviceName } } - }); - } - }, - [serviceName], - { preservePreviousData: false } - ); - - // config settings - const [sampleRate, setSampleRate] = useState<string>( - ( - selectedConfig?.settings.transaction_sample_rate || - defaultSettings.TRANSACTION_SAMPLE_RATE - ).toString() - ); - const [captureBody, setCaptureBody] = useState<string>( - selectedConfig?.settings.capture_body || defaultSettings.CAPTURE_BODY - ); - const [transactionMaxSpans, setTransactionMaxSpans] = useState<string>( - ( - selectedConfig?.settings.transaction_max_spans || - defaultSettings.TRANSACTION_MAX_SPANS - ).toString() - ); - - const isRumService = isRumAgentName(agentName); - const isSampleRateValid = isRight(transactionSampleRateRt.decode(sampleRate)); - const isTransactionMaxSpansValid = isRight( - transactionMaxSpansRt.decode(transactionMaxSpans) - ); - - const isFormValid = - !!serviceName && - !!environment && - isSampleRateValid && - // captureBody and isTransactionMaxSpansValid are required except if service is RUM - (isRumService || (!!captureBody && isTransactionMaxSpansValid)) && - // agent name is required, except if serviceName is "all" - (serviceName === ALL_OPTION_VALUE || agentName !== undefined); - - const handleSubmitEvent = async ( - event: - | React.FormEvent<HTMLFormElement> - | React.MouseEvent<HTMLButtonElement> - ) => { - event.preventDefault(); - setIsSaving(true); - - await saveConfig({ - serviceName, - environment, - sampleRate, - captureBody, - transactionMaxSpans, - agentName, - isExistingConfig: Boolean(selectedConfig), - toasts, - trackApmEvent - }); - setIsSaving(false); - onSaved(); - }; - - return ( - <EuiPortal> - <EuiFlyout size="s" onClose={onClose} ownFocus={true}> - <EuiFlyoutHeader hasBorder> - <EuiTitle> - <h2> - {selectedConfig - ? i18n.translate( - 'xpack.apm.settings.agentConf.editConfigTitle', - { defaultMessage: 'Edit configuration' } - ) - : i18n.translate( - 'xpack.apm.settings.agentConf.createConfigTitle', - { defaultMessage: 'Create configuration' } - )} - </h2> - </EuiTitle> - </EuiFlyoutHeader> - <EuiFlyoutBody> - <EuiText size="s"> - This allows you to fine-tune your agent configuration directly in - Kibana. Best of all, changes are automatically propagated to your - APM agents so there’s no need to redeploy. - </EuiText> - - <EuiSpacer size="m" /> - - <EuiForm> - {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} - <form - onKeyPress={e => { - const didClickEnter = e.which === 13; - if (didClickEnter) { - handleSubmitEvent(e); - } - }} - > - <ServiceSection - isReadOnly={Boolean(selectedConfig)} - // - // environment - environment={environment} - onEnvironmentChange={setEnvironment} - // - // serviceName - serviceName={serviceName} - onServiceNameChange={setServiceName} - /> - - <EuiSpacer /> - - <SettingsSection - isRumService={isRumService} - // - // sampleRate - sampleRate={sampleRate} - setSampleRate={setSampleRate} - isSampleRateValid={isSampleRateValid} - // - // captureBody - captureBody={captureBody} - setCaptureBody={setCaptureBody} - // - // transactionMaxSpans - transactionMaxSpans={transactionMaxSpans} - setTransactionMaxSpans={setTransactionMaxSpans} - isTransactionMaxSpansValid={isTransactionMaxSpansValid} - /> - </form> - </EuiForm> - </EuiFlyoutBody> - <EuiFlyoutFooter> - <EuiFlexGroup justifyContent="spaceBetween"> - <EuiFlexItem grow={false}> - {selectedConfig ? ( - <DeleteButton - selectedConfig={selectedConfig} - onDeleted={onDeleted} - /> - ) : null} - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiFlexGroup justifyContent="flexEnd"> - <EuiFlexItem grow={false}> - <EuiButtonEmpty onClick={onClose}> - {i18n.translate( - 'xpack.apm.settings.agentConf.cancelButtonLabel', - { defaultMessage: 'Cancel' } - )} - </EuiButtonEmpty> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButton - type="submit" - fill - isLoading={isSaving} - iconSide="right" - isDisabled={!isFormValid} - onClick={handleSubmitEvent} - > - {i18n.translate( - 'xpack.apm.settings.agentConf.saveConfigurationButtonLabel', - { defaultMessage: 'Save' } - )} - </EuiButton> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlyoutFooter> - </EuiFlyout> - </EuiPortal> - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/saveConfig.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/saveConfig.ts deleted file mode 100644 index 229394cb5da8c..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/saveConfig.ts +++ /dev/null @@ -1,107 +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 { i18n } from '@kbn/i18n'; -import { NotificationsStart } from 'kibana/public'; -import { callApmApi } from '../../../../../services/rest/createCallApmApi'; -import { isRumAgentName } from '../../../../../../../../../plugins/apm/common/agent_name'; -import { - getOptionLabel, - omitAllOption -} from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { UiTracker } from '../../../../../../../../../plugins/observability/public'; - -interface Settings { - transaction_sample_rate: number; - capture_body?: string; - transaction_max_spans?: number; -} - -export async function saveConfig({ - serviceName, - environment, - sampleRate, - captureBody, - transactionMaxSpans, - agentName, - isExistingConfig, - toasts, - trackApmEvent -}: { - serviceName: string; - environment: string; - sampleRate: string; - captureBody: string; - transactionMaxSpans: string; - agentName?: string; - isExistingConfig: boolean; - toasts: NotificationsStart['toasts']; - trackApmEvent: UiTracker; -}) { - trackApmEvent({ metric: 'save_agent_configuration' }); - - try { - const settings: Settings = { - transaction_sample_rate: Number(sampleRate) - }; - - if (!isRumAgentName(agentName)) { - settings.capture_body = captureBody; - settings.transaction_max_spans = Number(transactionMaxSpans); - } - - const configuration = { - agent_name: agentName, - service: { - name: omitAllOption(serviceName), - environment: omitAllOption(environment) - }, - settings - }; - - await callApmApi({ - pathname: '/api/apm/settings/agent-configuration', - method: 'PUT', - params: { - query: { overwrite: isExistingConfig }, - body: configuration - } - }); - - toasts.addSuccess({ - title: i18n.translate( - 'xpack.apm.settings.agentConf.saveConfig.succeeded.title', - { defaultMessage: 'Configuration saved' } - ), - text: i18n.translate( - 'xpack.apm.settings.agentConf.saveConfig.succeeded.text', - { - defaultMessage: - 'The configuration for "{serviceName}" was saved. It will take some time to propagate to the agents.', - values: { serviceName: getOptionLabel(serviceName) } - } - ) - }); - } catch (error) { - toasts.addDanger({ - title: i18n.translate( - 'xpack.apm.settings.agentConf.saveConfig.failed.title', - { defaultMessage: 'Configuration could not be saved' } - ), - text: i18n.translate( - 'xpack.apm.settings.agentConf.saveConfig.failed.text', - { - defaultMessage: - 'Something went wrong when saving the configuration for "{serviceName}". Error: "{errorMessage}"', - values: { - serviceName: getOptionLabel(serviceName), - errorMessage: error.message - } - } - ) - }); - } -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/FormRowSelect.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/FormRowSelect.tsx new file mode 100644 index 0000000000000..30d3f9580db48 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/FormRowSelect.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiDescribedFormGroup, + EuiSelectOption, + EuiFormRow +} from '@elastic/eui'; +import { SelectWithPlaceholder } from '../../../../../shared/SelectWithPlaceholder'; + +interface Props { + title: string; + description: string; + fieldLabel: string; + isLoading: boolean; + options?: EuiSelectOption[]; + value?: string; + disabled: boolean; + onChange: (event: React.ChangeEvent<HTMLSelectElement>) => void; +} + +export function FormRowSelect({ + title, + description, + fieldLabel, + isLoading, + options, + value, + disabled, + onChange +}: Props) { + return ( + <EuiDescribedFormGroup + fullWidth + title={<h3>{title}</h3>} + description={description} + > + <EuiFormRow label={fieldLabel}> + <SelectWithPlaceholder + isLoading={isLoading} + options={options} + value={value} + disabled={disabled} + onChange={onChange} + /> + </EuiFormRow> + </EuiDescribedFormGroup> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx new file mode 100644 index 0000000000000..b9f8fd86d067b --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx @@ -0,0 +1,208 @@ +/* + * 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 { + EuiTitle, + EuiSpacer, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiButton +} from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { isString } from 'lodash'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { AgentConfigurationIntake } from '../../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { + omitAllOption, + getOptionLabel +} from '../../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; +import { useFetcher, FETCH_STATUS } from '../../../../../../hooks/useFetcher'; +import { FormRowSelect } from './FormRowSelect'; +import { APMLink } from '../../../../../shared/Links/apm/APMLink'; + +interface Props { + newConfig: AgentConfigurationIntake; + setNewConfig: React.Dispatch<React.SetStateAction<AgentConfigurationIntake>>; + onClickNext: () => void; +} + +export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { + const { data: serviceNames = [], status: serviceNamesStatus } = useFetcher( + callApmApi => { + return callApmApi({ + pathname: '/api/apm/settings/agent-configuration/services', + forceCache: true + }); + }, + [], + { preservePreviousData: false } + ); + + const { data: environments = [], status: environmentStatus } = useFetcher( + callApmApi => { + if (newConfig.service.name) { + return callApmApi({ + pathname: '/api/apm/settings/agent-configuration/environments', + params: { + query: { serviceName: omitAllOption(newConfig.service.name) } + } + }); + } + }, + [newConfig.service.name], + { preservePreviousData: false } + ); + + const { status: agentNameStatus } = useFetcher( + async callApmApi => { + const serviceName = newConfig.service.name; + + if (!isString(serviceName) || serviceName.length === 0) { + return; + } + + const { agentName } = await callApmApi({ + pathname: '/api/apm/settings/agent-configuration/agent_name', + params: { query: { serviceName } } + }); + + setNewConfig(prev => ({ ...prev, agent_name: agentName })); + }, + [newConfig.service.name, setNewConfig] + ); + + const ALREADY_CONFIGURED_TRANSLATED = i18n.translate( + 'xpack.apm.agentConfig.servicePage.alreadyConfiguredOption', + { defaultMessage: 'already configured' } + ); + + const serviceNameOptions = serviceNames.map(name => ({ + text: getOptionLabel(name), + value: name + })); + const environmentOptions = environments.map( + ({ name, alreadyConfigured }) => ({ + disabled: alreadyConfigured, + text: `${getOptionLabel(name)} ${ + alreadyConfigured ? `(${ALREADY_CONFIGURED_TRANSLATED})` : '' + }`, + value: name + }) + ); + + return ( + <EuiPanel paddingSize="m"> + <EuiTitle size="xs"> + <h3> + {i18n.translate('xpack.apm.agentConfig.servicePage.title', { + defaultMessage: 'Choose service' + })} + </h3> + </EuiTitle> + + <EuiSpacer size="m" /> + + {/* Service name options */} + <FormRowSelect + title={i18n.translate( + 'xpack.apm.agentConfig.servicePage.service.title', + { defaultMessage: 'Service' } + )} + description={i18n.translate( + 'xpack.apm.agentConfig.servicePage.service.description', + { defaultMessage: 'Choose the service you want to configure.' } + )} + fieldLabel={i18n.translate( + 'xpack.apm.agentConfig.servicePage.service.fieldLabel', + { defaultMessage: 'Service name' } + )} + isLoading={serviceNamesStatus === FETCH_STATUS.LOADING} + options={serviceNameOptions} + value={newConfig.service.name} + disabled={serviceNamesStatus === FETCH_STATUS.LOADING} + onChange={e => { + e.preventDefault(); + const name = e.target.value; + setNewConfig(prev => ({ + ...prev, + service: { name, environment: '' } + })); + }} + /> + + {/* Environment options */} + <FormRowSelect + title={i18n.translate( + 'xpack.apm.agentConfig.servicePage.environment.title', + { defaultMessage: 'Environment' } + )} + description={i18n.translate( + 'xpack.apm.agentConfig.servicePage.environment.description', + { + defaultMessage: + 'Only a single environment per configuration is supported.' + } + )} + fieldLabel={i18n.translate( + 'xpack.apm.agentConfig.servicePage.environment.fieldLabel', + { defaultMessage: 'Service environment' } + )} + isLoading={environmentStatus === FETCH_STATUS.LOADING} + options={environmentOptions} + value={newConfig.service.environment} + disabled={ + !newConfig.service.name || environmentStatus === FETCH_STATUS.LOADING + } + onChange={e => { + e.preventDefault(); + const environment = e.target.value; + setNewConfig(prev => ({ + ...prev, + service: { name: prev.service.name, environment } + })); + }} + /> + + <EuiSpacer /> + + <EuiFlexGroup justifyContent="flexEnd"> + {/* Cancel button */} + <EuiFlexItem grow={false}> + <APMLink path="/settings/agent-configuration"> + <EuiButtonEmpty color="primary"> + {i18n.translate( + 'xpack.apm.agentConfig.servicePage.cancelButton', + { defaultMessage: 'Cancel' } + )} + </EuiButtonEmpty> + </APMLink> + </EuiFlexItem> + + {/* Next button */} + <EuiFlexItem grow={false}> + <EuiButton + type="submit" + fill + onClick={onClickNext} + isLoading={agentNameStatus === FETCH_STATUS.LOADING} + isDisabled={ + !newConfig.service.name || + !newConfig.service.environment || + agentNameStatus === FETCH_STATUS.LOADING + } + > + {i18n.translate( + 'xpack.apm.agentConfig.saveConfigurationButtonLabel', + { defaultMessage: 'Next step' } + )} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiPanel> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx new file mode 100644 index 0000000000000..b1959e4d68aa4 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx @@ -0,0 +1,178 @@ +/* + * 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 from 'react'; +import { + EuiFormRow, + EuiFieldText, + EuiFieldNumber, + EuiDescribedFormGroup, + EuiFlexGroup, + EuiFlexItem, + EuiCode, + EuiSpacer, + EuiIconTip +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SettingDefinition } from '../../../../../../../../../../plugins/apm/common/agent_configuration/setting_definitions/types'; +import { isValid } from '../../../../../../../../../../plugins/apm/common/agent_configuration/setting_definitions'; +import { + amountAndUnitToString, + amountAndUnitToObject +} from '../../../../../../../../../../plugins/apm/common/agent_configuration/amount_and_unit'; +import { SelectWithPlaceholder } from '../../../../../shared/SelectWithPlaceholder'; + +function FormRow({ + setting, + value, + onChange +}: { + setting: SettingDefinition; + value?: string; + onChange: (key: string, value: string) => void; +}) { + switch (setting.type) { + case 'float': + case 'text': { + return ( + <EuiFieldText + placeholder={setting.placeholder} + value={value || ''} + onChange={e => onChange(setting.key, e.target.value)} + /> + ); + } + + case 'integer': { + return ( + <EuiFieldNumber + placeholder={setting.placeholder} + value={(value as any) || ''} + min={setting.min} + max={setting.max} + onChange={e => onChange(setting.key, e.target.value)} + /> + ); + } + + case 'select': { + return ( + <SelectWithPlaceholder + placeholder={setting.placeholder} + options={setting.options} + value={value} + onChange={e => onChange(setting.key, e.target.value)} + /> + ); + } + + case 'boolean': { + return ( + <SelectWithPlaceholder + placeholder={setting.placeholder} + options={[{ text: 'true' }, { text: 'false' }]} + value={value} + onChange={e => onChange(setting.key, e.target.value)} + /> + ); + } + + case 'bytes': + case 'duration': { + const { amount, unit } = amountAndUnitToObject(value ?? ''); + + return ( + <EuiFlexGroup gutterSize="s"> + <EuiFlexItem grow={false}> + <EuiFieldNumber + placeholder={setting.placeholder} + value={(amount as unknown) as number} + onChange={e => + onChange( + setting.key, + amountAndUnitToString({ amount: e.target.value, unit }) + ) + } + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <SelectWithPlaceholder + placeholder={i18n.translate('xpack.apm.unitLabel', { + defaultMessage: 'Select unit' + })} + value={unit} + options={setting.units?.map(text => ({ text }))} + onChange={e => + onChange( + setting.key, + amountAndUnitToString({ amount, unit: e.target.value }) + ) + } + /> + </EuiFlexItem> + </EuiFlexGroup> + ); + } + + default: + throw new Error(`Unknown type "${(setting as SettingDefinition).type}"`); + } +} + +export function SettingFormRow({ + isUnsaved, + setting, + value, + onChange +}: { + isUnsaved: boolean; + setting: SettingDefinition; + value?: string; + onChange: (key: string, value: string) => void; +}) { + const isInvalid = value != null && value !== '' && !isValid(setting, value); + + return ( + <EuiDescribedFormGroup + fullWidth + title={ + <h3> + {setting.label}{' '} + {isUnsaved && ( + <EuiIconTip + type={'dot'} + color={'warning'} + content={i18n.translate( + 'xpack.apm.agentConfig.unsavedSetting.tooltip', + { defaultMessage: 'Unsaved' } + )} + /> + )} + </h3> + } + description={ + <> + {setting.description} + + {setting.defaultValue && ( + <> + <EuiSpacer /> + <EuiCode>Default: {setting.defaultValue}</EuiCode> + </> + )} + </> + } + > + <EuiFormRow + label={setting.key} + error={setting.validationError} + isInvalid={isInvalid} + > + <FormRow onChange={onChange} setting={setting} value={value} /> + </EuiFormRow> + </EuiDescribedFormGroup> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx new file mode 100644 index 0000000000000..6d76b69600333 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx @@ -0,0 +1,301 @@ +/* + * 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 { + EuiButton, + EuiForm, + EuiTitle, + EuiSpacer, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiStat, + EuiBottomBar, + EuiText, + EuiHealth, + EuiLoadingSpinner +} from '@elastic/eui'; +import React, { useState, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { EuiCallOut } from '@elastic/eui'; +import { FETCH_STATUS } from '../../../../../../hooks/useFetcher'; +import { AgentName } from '../../../../../../../../../../plugins/apm/typings/es_schemas/ui/fields/agent'; +import { history } from '../../../../../../utils/history'; +import { AgentConfigurationIntake } from '../../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { + filterByAgent, + settingDefinitions, + isValid +} from '../../../../../../../../../../plugins/apm/common/agent_configuration/setting_definitions'; +import { saveConfig } from './saveConfig'; +import { useApmPluginContext } from '../../../../../../hooks/useApmPluginContext'; +import { useUiTracker } from '../../../../../../../../../../plugins/observability/public'; +import { SettingFormRow } from './SettingFormRow'; +import { getOptionLabel } from '../../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; + +function removeEmpty<T>(obj: T): T { + return Object.fromEntries( + Object.entries(obj).filter(([k, v]) => v != null && v !== '') + ); +} + +export function SettingsPage({ + status, + unsavedChanges, + newConfig, + setNewConfig, + resetSettings, + isEditMode, + onClickEdit +}: { + status?: FETCH_STATUS; + unsavedChanges: Record<string, string>; + newConfig: AgentConfigurationIntake; + setNewConfig: React.Dispatch<React.SetStateAction<AgentConfigurationIntake>>; + resetSettings: () => void; + isEditMode: boolean; + onClickEdit: () => void; +}) { + // get a telemetry UI event tracker + const trackApmEvent = useUiTracker({ app: 'apm' }); + const { toasts } = useApmPluginContext().core.notifications; + const [isSaving, setIsSaving] = useState(false); + const unsavedChangesCount = Object.keys(unsavedChanges).length; + const isLoading = status === FETCH_STATUS.LOADING; + + const isFormValid = useMemo(() => { + return ( + settingDefinitions + // only validate settings that are not empty + .filter(({ key }) => { + const value = newConfig.settings[key]; + return value != null && value !== ''; + }) + + // every setting must be valid for the form to be valid + .every(def => { + const value = newConfig.settings[def.key]; + return isValid(def, value); + }) + ); + }, [newConfig.settings]); + + const handleSubmitEvent = async () => { + trackApmEvent({ metric: 'save_agent_configuration' }); + const config = { ...newConfig, settings: removeEmpty(newConfig.settings) }; + + setIsSaving(true); + await saveConfig({ config, isEditMode, toasts }); + setIsSaving(false); + + // go back to overview + history.push({ + pathname: '/settings/agent-configuration', + search: history.location.search + }); + }; + + if (status === FETCH_STATUS.FAILURE) { + return ( + <EuiCallOut + title={i18n.translate( + 'xpack.apm.agentConfig.settingsPage.notFound.title', + { defaultMessage: 'Sorry, there was an error' } + )} + color="danger" + iconType="alert" + > + <p> + {i18n.translate( + 'xpack.apm.agentConfig.settingsPage.notFound.message', + { defaultMessage: 'The requested configuration does not exist' } + )} + </p> + </EuiCallOut> + ); + } + + return ( + <> + <EuiForm> + {/* Since the submit button is placed outside the form we cannot use `onSubmit` and have to use `onKeyPress` to submit the form on enter */} + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} + <form + onKeyPress={e => { + const didClickEnter = e.which === 13; + if (didClickEnter && isFormValid) { + e.preventDefault(); + handleSubmitEvent(); + } + }} + > + {/* Selected Service panel */} + <EuiPanel paddingSize="m"> + <EuiTitle size="s"> + <h3> + {i18n.translate('xpack.apm.agentConfig.chooseService.title', { + defaultMessage: 'Choose service' + })} + </h3> + </EuiTitle> + + <EuiSpacer size="m" /> + + <EuiFlexGroup> + <EuiFlexItem> + <EuiStat + titleSize="xs" + title={ + isLoading ? '-' : getOptionLabel(newConfig.service.name) + } + description={i18n.translate( + 'xpack.apm.agentConfig.chooseService.service.name.label', + { defaultMessage: 'Service name' } + )} + /> + </EuiFlexItem> + <EuiFlexItem> + <EuiStat + titleSize="xs" + title={ + isLoading + ? '-' + : getOptionLabel(newConfig.service.environment) + } + description={i18n.translate( + 'xpack.apm.agentConfig.chooseService.service.environment.label', + { defaultMessage: 'Environment' } + )} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + {!isEditMode && ( + <EuiButton onClick={onClickEdit} iconType="pencil"> + {i18n.translate( + 'xpack.apm.agentConfig.chooseService.editButton', + { defaultMessage: 'Edit' } + )} + </EuiButton> + )} + </EuiFlexItem> + </EuiFlexGroup> + </EuiPanel> + + <EuiSpacer size="m" /> + + {/* Settings panel */} + <EuiPanel paddingSize="m"> + <EuiTitle size="s"> + <h3> + {i18n.translate('xpack.apm.agentConfig.settings.title', { + defaultMessage: 'Configuration options' + })} + </h3> + </EuiTitle> + + <EuiSpacer size="m" /> + + {isLoading ? ( + <div style={{ textAlign: 'center' }}> + <EuiLoadingSpinner size="m" /> + </div> + ) : ( + renderSettings({ unsavedChanges, newConfig, setNewConfig }) + )} + </EuiPanel> + </form> + </EuiForm> + <EuiSpacer size="xxl" /> + + {/* Bottom bar with save button */} + {unsavedChangesCount > 0 && ( + <EuiBottomBar paddingSize="s"> + <EuiFlexGroup justifyContent="spaceBetween" alignItems="center"> + <EuiFlexItem + grow={false} + style={{ + flexDirection: 'row', + alignItems: 'center' + }} + > + <EuiHealth color="warning" /> + <EuiText> + {i18n.translate('xpack.apm.unsavedChanges', { + defaultMessage: + '{unsavedChangesCount, plural, =0{0 unsaved changes} one {1 unsaved change} other {# unsaved changes}} ', + values: { unsavedChangesCount } + })} + </EuiText> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFlexGroup justifyContent="flexEnd"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty color="ghost" onClick={resetSettings}> + {i18n.translate( + 'xpack.apm.agentConfig.settingsPage.discardChangesButton', + { defaultMessage: 'Discard changes' } + )} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton + onClick={handleSubmitEvent} + fill + isLoading={isSaving} + isDisabled={!isFormValid} + color="secondary" + iconType="check" + > + {i18n.translate( + 'xpack.apm.agentConfig.settingsPage.saveButton', + { defaultMessage: 'Save configuration' } + )} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </EuiBottomBar> + )} + </> + ); +} + +function renderSettings({ + newConfig, + unsavedChanges, + setNewConfig +}: { + newConfig: AgentConfigurationIntake; + unsavedChanges: Record<string, string>; + setNewConfig: React.Dispatch<React.SetStateAction<AgentConfigurationIntake>>; +}) { + return ( + settingDefinitions + + // filter out agent specific items that are not applicable + // to the selected service + .filter(filterByAgent(newConfig.agent_name as AgentName)) + .map(setting => ( + <SettingFormRow + isUnsaved={unsavedChanges.hasOwnProperty(setting.key)} + key={setting.key} + setting={setting} + value={newConfig.settings[setting.key]} + onChange={(key, value) => { + setNewConfig(prev => ({ + ...prev, + settings: { + ...prev.settings, + [key]: value + } + })); + }} + /> + )) + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts new file mode 100644 index 0000000000000..7e3bcd68699be --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { NotificationsStart } from 'kibana/public'; +import { AgentConfigurationIntake } from '../../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { + getOptionLabel, + omitAllOption +} from '../../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; +import { callApmApi } from '../../../../../../services/rest/createCallApmApi'; + +export async function saveConfig({ + config, + isEditMode, + toasts +}: { + config: AgentConfigurationIntake; + agentName?: string; + isEditMode: boolean; + toasts: NotificationsStart['toasts']; +}) { + try { + await callApmApi({ + pathname: '/api/apm/settings/agent-configuration', + method: 'PUT', + params: { + query: { overwrite: isEditMode }, + body: { + ...config, + service: { + name: omitAllOption(config.service.name), + environment: omitAllOption(config.service.environment) + } + } + } + }); + + toasts.addSuccess({ + title: i18n.translate( + 'xpack.apm.agentConfig.saveConfig.succeeded.title', + { defaultMessage: 'Configuration saved' } + ), + text: i18n.translate('xpack.apm.agentConfig.saveConfig.succeeded.text', { + defaultMessage: + 'The configuration for "{serviceName}" was saved. It will take some time to propagate to the agents.', + values: { serviceName: getOptionLabel(config.service.name) } + }) + }); + } catch (error) { + toasts.addDanger({ + title: i18n.translate('xpack.apm.agentConfig.saveConfig.failed.title', { + defaultMessage: 'Configuration could not be saved' + }), + text: i18n.translate('xpack.apm.agentConfig.saveConfig.failed.text', { + defaultMessage: + 'Something went wrong when saving the configuration for "{serviceName}". Error: "{errorMessage}"', + values: { + serviceName: getOptionLabel(config.service.name), + errorMessage: error.message + } + }) + }); + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx new file mode 100644 index 0000000000000..531e557b6ef86 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx @@ -0,0 +1,63 @@ +/* + * 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. + */ + +/* + * 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 { storiesOf } from '@storybook/react'; +import React from 'react'; +import { HttpSetup } from 'kibana/public'; +import { AgentConfiguration } from '../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { FETCH_STATUS } from '../../../../../hooks/useFetcher'; +import { createCallApmApi } from '../../../../../services/rest/createCallApmApi'; +import { AgentConfigurationCreateEdit } from './index'; +import { + ApmPluginContext, + ApmPluginContextValue +} from '../../../../../context/ApmPluginContext'; + +storiesOf( + 'app/Settings/AgentConfigurations/AgentConfigurationCreateEdit', + module +).add( + 'with config', + () => { + const httpMock = {}; + + // mock + createCallApmApi((httpMock as unknown) as HttpSetup); + + const contextMock = { + core: { + notifications: { toasts: { addWarning: () => {}, addDanger: () => {} } } + } + }; + return ( + <ApmPluginContext.Provider + value={(contextMock as unknown) as ApmPluginContextValue} + > + <AgentConfigurationCreateEdit + pageStep="choose-settings-step" + existingConfigResult={{ + status: FETCH_STATUS.SUCCESS, + data: { + service: { name: 'opbeans-node', environment: 'production' }, + settings: {} + } as AgentConfiguration + }} + /> + </ApmPluginContext.Provider> + ); + }, + { + info: { + source: false + } + } +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx new file mode 100644 index 0000000000000..638e518563f8c --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx @@ -0,0 +1,157 @@ +/* + * 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 { isEmpty } from 'lodash'; +import { EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; +import React, { useState, useEffect, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FetcherResult } from '../../../../../hooks/useFetcher'; +import { history } from '../../../../../utils/history'; +import { + AgentConfigurationIntake, + AgentConfiguration +} from '../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { ServicePage } from './ServicePage/ServicePage'; +import { SettingsPage } from './SettingsPage/SettingsPage'; +import { fromQuery, toQuery } from '../../../../shared/Links/url_helpers'; + +type PageStep = 'choose-service-step' | 'choose-settings-step' | 'review-step'; + +function getInitialNewConfig( + existingConfig: AgentConfigurationIntake | undefined +) { + return { + agent_name: existingConfig?.agent_name, + service: existingConfig?.service || {}, + settings: existingConfig?.settings || {} + }; +} + +function setPage(pageStep: PageStep) { + history.push({ + ...history.location, + search: fromQuery({ + ...toQuery(history.location.search), + pageStep + }) + }); +} + +function getUnsavedChanges({ + newConfig, + existingConfig +}: { + newConfig: AgentConfigurationIntake; + existingConfig?: AgentConfigurationIntake; +}) { + return Object.fromEntries( + Object.entries(newConfig.settings).filter(([key, value]) => { + const existingValue = existingConfig?.settings?.[key]; + + // don't highlight changes that were added and removed + if (value === '' && existingValue == null) { + return false; + } + + return existingValue !== value; + }) + ); +} + +export function AgentConfigurationCreateEdit({ + pageStep, + existingConfigResult +}: { + pageStep: PageStep; + existingConfigResult?: FetcherResult<AgentConfiguration>; +}) { + const existingConfig = existingConfigResult?.data; + const isEditMode = Boolean(existingConfigResult); + const [newConfig, setNewConfig] = useState<AgentConfigurationIntake>( + getInitialNewConfig(existingConfig) + ); + + const resetSettings = useCallback(() => { + setNewConfig(_newConfig => ({ + ..._newConfig, + settings: existingConfig?.settings || {} + })); + }, [existingConfig]); + + // update newConfig when existingConfig has loaded + useEffect(() => { + setNewConfig(getInitialNewConfig(existingConfig)); + }, [existingConfig]); + + useEffect(() => { + // the user tried to edit the service of an existing config + if (pageStep === 'choose-service-step' && isEditMode) { + setPage('choose-settings-step'); + } + + // the user skipped the first step (select service) + if ( + pageStep === 'choose-settings-step' && + !isEditMode && + isEmpty(newConfig.service) + ) { + setPage('choose-service-step'); + } + }, [isEditMode, newConfig, pageStep]); + + const unsavedChanges = getUnsavedChanges({ newConfig, existingConfig }); + + return ( + <> + <EuiTitle> + <h2> + {isEditMode + ? i18n.translate('xpack.apm.agentConfig.editConfigTitle', { + defaultMessage: 'Edit configuration' + }) + : i18n.translate('xpack.apm.agentConfig.createConfigTitle', { + defaultMessage: 'Create configuration' + })} + </h2> + </EuiTitle> + + <EuiText size="s"> + {i18n.translate('xpack.apm.agentConfig.newConfig.description', { + defaultMessage: `This allows you to fine-tune your agent configuration directly in + Kibana. Best of all, changes are automatically propagated to your APM + agents so there’s no need to redeploy.` + })} + </EuiText> + + <EuiSpacer size="m" /> + + {pageStep === 'choose-service-step' && ( + <ServicePage + newConfig={newConfig} + setNewConfig={setNewConfig} + onClickNext={() => setPage('choose-settings-step')} + /> + )} + + {pageStep === 'choose-settings-step' && ( + <SettingsPage + status={existingConfigResult?.status} + unsavedChanges={unsavedChanges} + onClickEdit={() => setPage('choose-service-step')} + newConfig={newConfig} + setNewConfig={setNewConfig} + resetSettings={resetSettings} + isEditMode={isEditMode} + /> + )} + + {/* + TODO: Add review step + {pageStep === 'review-step' && <div>Review will be here </div>} + */} + </> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationList.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationList.tsx deleted file mode 100644 index 557945e9ba67a..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationList.tsx +++ /dev/null @@ -1,232 +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 React from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiEmptyPrompt, - EuiButton, - EuiButtonEmpty, - EuiHealth, - EuiToolTip -} from '@elastic/eui'; -import { isEmpty } from 'lodash'; -import theme from '@elastic/eui/dist/eui_theme_light.json'; -import { FETCH_STATUS } from '../../../../hooks/useFetcher'; -import { ITableColumn, ManagedTable } from '../../../shared/ManagedTable'; -import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { AgentConfigurationListAPIResponse } from '../../../../../../../../plugins/apm/server/lib/settings/agent_configuration/list_configurations'; -import { Config } from '.'; -import { TimestampTooltip } from '../../../shared/TimestampTooltip'; -import { px, units } from '../../../../style/variables'; -import { getOptionLabel } from '../../../../../../../../plugins/apm/common/agent_configuration_constants'; - -export function AgentConfigurationList({ - status, - data, - setIsFlyoutOpen, - setSelectedConfig -}: { - status: FETCH_STATUS; - data: AgentConfigurationListAPIResponse; - setIsFlyoutOpen: (val: boolean) => void; - setSelectedConfig: (val: Config | null) => void; -}) { - const columns: Array<ITableColumn<Config>> = [ - { - field: 'applied_by_agent', - align: 'center', - width: px(units.double), - name: '', - sortable: true, - render: (isApplied: boolean) => ( - <EuiToolTip - content={ - isApplied - ? i18n.translate( - 'xpack.apm.settings.agentConf.configTable.appliedTooltipMessage', - { defaultMessage: 'Applied by at least one agent' } - ) - : i18n.translate( - 'xpack.apm.settings.agentConf.configTable.notAppliedTooltipMessage', - { defaultMessage: 'Not yet applied by any agents' } - ) - } - > - <EuiHealth color={isApplied ? 'success' : theme.euiColorLightShade} /> - </EuiToolTip> - ) - }, - { - field: 'service.name', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.serviceNameColumnLabel', - { defaultMessage: 'Service name' } - ), - sortable: true, - render: (_, config: Config) => ( - <EuiButtonEmpty - flush="left" - size="s" - color="primary" - onClick={() => { - setSelectedConfig(config); - setIsFlyoutOpen(true); - }} - > - {getOptionLabel(config.service.name)} - </EuiButtonEmpty> - ) - }, - { - field: 'service.environment', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.environmentColumnLabel', - { defaultMessage: 'Service environment' } - ), - sortable: true, - render: (value: string) => getOptionLabel(value) - }, - { - field: 'settings.transaction_sample_rate', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.sampleRateColumnLabel', - { defaultMessage: 'Sample rate' } - ), - dataType: 'number', - sortable: true, - render: (value: number) => value - }, - { - field: 'settings.capture_body', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.captureBodyColumnLabel', - { defaultMessage: 'Capture body' } - ), - sortable: true, - render: (value: string) => value - }, - { - field: 'settings.transaction_max_spans', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.transactionMaxSpansColumnLabel', - { defaultMessage: 'Transaction max spans' } - ), - dataType: 'number', - sortable: true, - render: (value: number) => value - }, - { - align: 'right', - field: '@timestamp', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.lastUpdatedColumnLabel', - { defaultMessage: 'Last updated' } - ), - sortable: true, - render: (value: number) => ( - <TimestampTooltip time={value} timeUnit="minutes" /> - ) - }, - { - width: px(units.double), - name: '', - actions: [ - { - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.editButtonLabel', - { defaultMessage: 'Edit' } - ), - description: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.editButtonDescription', - { defaultMessage: 'Edit this config' } - ), - icon: 'pencil', - color: 'primary', - type: 'icon', - onClick: (config: Config) => { - setSelectedConfig(config); - setIsFlyoutOpen(true); - } - } - ] - } - ]; - - const emptyStatePrompt = ( - <EuiEmptyPrompt - iconType="controlsHorizontal" - title={ - <h2> - {i18n.translate( - 'xpack.apm.settings.agentConf.configTable.emptyPromptTitle', - { defaultMessage: 'No configurations found.' } - )} - </h2> - } - body={ - <> - <p> - {i18n.translate( - 'xpack.apm.settings.agentConf.configTable.emptyPromptText', - { - defaultMessage: - "Let's change that! You can fine-tune agent configuration directly from Kibana without having to redeploy. Get started by creating your first configuration." - } - )} - </p> - </> - } - actions={ - <EuiButton color="primary" fill onClick={() => setIsFlyoutOpen(true)}> - {i18n.translate( - 'xpack.apm.settings.agentConf.configTable.createConfigButtonLabel', - { defaultMessage: 'Create configuration' } - )} - </EuiButton> - } - /> - ); - - const failurePrompt = ( - <EuiEmptyPrompt - iconType="alert" - body={ - <> - <p> - {i18n.translate( - 'xpack.apm.settings.agentConf.configTable.configTable.failurePromptText', - { - defaultMessage: - 'The list of agent configurations could not be fetched. Your user may not have the sufficient permissions.' - } - )} - </p> - </> - } - /> - ); - - if (status === 'failure') { - return failurePrompt; - } - - if (status === 'success' && isEmpty(data)) { - return emptyStatePrompt; - } - - return ( - <ManagedTable - noItemsMessage={<LoadingStatePrompt />} - columns={columns} - items={data} - initialSortField="service.name" - initialSortDirection="asc" - initialPageSize={50} - /> - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx new file mode 100644 index 0000000000000..267aaddc93f76 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { NotificationsStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AgentConfigurationListAPIResponse } from '../../../../../../../../../plugins/apm/server/lib/settings/agent_configuration/list_configurations'; +import { getOptionLabel } from '../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; +import { callApmApi } from '../../../../../services/rest/createCallApmApi'; +import { useApmPluginContext } from '../../../../../hooks/useApmPluginContext'; + +type Config = AgentConfigurationListAPIResponse[0]; + +interface Props { + config: Config; + onCancel: () => void; + onConfirm: () => void; +} + +export function ConfirmDeleteModal({ config, onCancel, onConfirm }: Props) { + const [isDeleting, setIsDeleting] = useState(false); + const { toasts } = useApmPluginContext().core.notifications; + + return ( + <EuiOverlayMask> + <EuiConfirmModal + title={i18n.translate('xpack.apm.agentConfig.deleteModal.title', { + defaultMessage: `Delete configuration` + })} + onCancel={onCancel} + onConfirm={async () => { + setIsDeleting(true); + await deleteConfig(config, toasts); + setIsDeleting(false); + onConfirm(); + }} + cancelButtonText={i18n.translate( + 'xpack.apm.agentConfig.deleteModal.cancel', + { defaultMessage: `Cancel` } + )} + confirmButtonText={i18n.translate( + 'xpack.apm.agentConfig.deleteModal.confirm', + { defaultMessage: `Delete` } + )} + confirmButtonDisabled={isDeleting} + buttonColor="danger" + defaultFocusedButton="confirm" + > + <p> + {i18n.translate('xpack.apm.agentConfig.deleteModal.text', { + defaultMessage: `You are about to delete the configuration for service "{serviceName}" and environment "{environment}".`, + values: { + serviceName: getOptionLabel(config.service.name), + environment: getOptionLabel(config.service.environment) + } + })} + </p> + </EuiConfirmModal> + </EuiOverlayMask> + ); +} + +async function deleteConfig( + config: Config, + toasts: NotificationsStart['toasts'] +) { + try { + await callApmApi({ + pathname: '/api/apm/settings/agent-configuration', + method: 'DELETE', + params: { + body: { + service: { + name: config.service.name, + environment: config.service.environment + } + } + } + }); + + toasts.addSuccess({ + title: i18n.translate( + 'xpack.apm.agentConfig.deleteSection.deleteConfigSucceededTitle', + { defaultMessage: 'Configuration was deleted' } + ), + text: i18n.translate( + 'xpack.apm.agentConfig.deleteSection.deleteConfigSucceededText', + { + defaultMessage: + 'You have successfully deleted a configuration for "{serviceName}". It will take some time to propagate to the agents.', + values: { serviceName: getOptionLabel(config.service.name) } + } + ) + }); + } catch (error) { + toasts.addDanger({ + title: i18n.translate( + 'xpack.apm.agentConfig.deleteSection.deleteConfigFailedTitle', + { defaultMessage: 'Configuration could not be deleted' } + ), + text: i18n.translate( + 'xpack.apm.agentConfig.deleteSection.deleteConfigFailedText', + { + defaultMessage: + 'Something went wrong when deleting a configuration for "{serviceName}". Error: "{errorMessage}"', + values: { + serviceName: getOptionLabel(config.service.name), + errorMessage: error.message + } + } + ) + }); + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx new file mode 100644 index 0000000000000..6d5f65121d8fd --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiEmptyPrompt, + EuiButton, + EuiButtonEmpty, + EuiHealth, + EuiToolTip, + EuiButtonIcon +} from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import { FETCH_STATUS } from '../../../../../hooks/useFetcher'; +import { ITableColumn, ManagedTable } from '../../../../shared/ManagedTable'; +import { LoadingStatePrompt } from '../../../../shared/LoadingStatePrompt'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AgentConfigurationListAPIResponse } from '../../../../../../../../../plugins/apm/server/lib/settings/agent_configuration/list_configurations'; +import { TimestampTooltip } from '../../../../shared/TimestampTooltip'; +import { px, units } from '../../../../../style/variables'; +import { getOptionLabel } from '../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; +import { + createAgentConfigurationHref, + editAgentConfigurationHref +} from '../../../../shared/Links/apm/agentConfigurationLinks'; +import { ConfirmDeleteModal } from './ConfirmDeleteModal'; + +type Config = AgentConfigurationListAPIResponse[0]; + +export function AgentConfigurationList({ + status, + data, + refetch +}: { + status: FETCH_STATUS; + data: Config[]; + refetch: () => void; +}) { + const [configToBeDeleted, setConfigToBeDeleted] = useState<Config | null>( + null + ); + + const emptyStatePrompt = ( + <EuiEmptyPrompt + iconType="controlsHorizontal" + title={ + <h2> + {i18n.translate( + 'xpack.apm.agentConfig.configTable.emptyPromptTitle', + { defaultMessage: 'No configurations found.' } + )} + </h2> + } + body={ + <> + <p> + {i18n.translate( + 'xpack.apm.agentConfig.configTable.emptyPromptText', + { + defaultMessage: + "Let's change that! You can fine-tune agent configuration directly from Kibana without having to redeploy. Get started by creating your first configuration." + } + )} + </p> + </> + } + actions={ + <EuiButton color="primary" fill href={createAgentConfigurationHref()}> + {i18n.translate( + 'xpack.apm.agentConfig.configTable.createConfigButtonLabel', + { defaultMessage: 'Create configuration' } + )} + </EuiButton> + } + /> + ); + + const failurePrompt = ( + <EuiEmptyPrompt + iconType="alert" + body={ + <> + <p> + {i18n.translate( + 'xpack.apm.agentConfig.configTable.configTable.failurePromptText', + { + defaultMessage: + 'The list of agent configurations could not be fetched. Your user may not have the sufficient permissions.' + } + )} + </p> + </> + } + /> + ); + + if (status === FETCH_STATUS.FAILURE) { + return failurePrompt; + } + + if (status === FETCH_STATUS.SUCCESS && isEmpty(data)) { + return emptyStatePrompt; + } + + const columns: Array<ITableColumn<Config>> = [ + { + field: 'applied_by_agent', + align: 'center', + width: px(units.double), + name: '', + sortable: true, + render: (isApplied: boolean) => ( + <EuiToolTip + content={ + isApplied + ? i18n.translate( + 'xpack.apm.agentConfig.configTable.appliedTooltipMessage', + { defaultMessage: 'Applied by at least one agent' } + ) + : i18n.translate( + 'xpack.apm.agentConfig.configTable.notAppliedTooltipMessage', + { defaultMessage: 'Not yet applied by any agents' } + ) + } + > + <EuiHealth color={isApplied ? 'success' : theme.euiColorLightShade} /> + </EuiToolTip> + ) + }, + { + field: 'service.name', + name: i18n.translate( + 'xpack.apm.agentConfig.configTable.serviceNameColumnLabel', + { defaultMessage: 'Service name' } + ), + sortable: true, + render: (_, config: Config) => ( + <EuiButtonEmpty + flush="left" + size="s" + color="primary" + href={editAgentConfigurationHref(config.service)} + > + {getOptionLabel(config.service.name)} + </EuiButtonEmpty> + ) + }, + { + field: 'service.environment', + name: i18n.translate( + 'xpack.apm.agentConfig.configTable.environmentColumnLabel', + { defaultMessage: 'Service environment' } + ), + sortable: true, + render: (environment: string) => getOptionLabel(environment) + }, + { + align: 'right', + field: '@timestamp', + name: i18n.translate( + 'xpack.apm.agentConfig.configTable.lastUpdatedColumnLabel', + { defaultMessage: 'Last updated' } + ), + sortable: true, + render: (value: number) => ( + <TimestampTooltip time={value} timeUnit="minutes" /> + ) + }, + { + width: px(units.double), + name: '', + render: (config: Config) => ( + <EuiButtonIcon + aria-label="Edit" + iconType="pencil" + href={editAgentConfigurationHref(config.service)} + /> + ) + }, + { + width: px(units.double), + name: '', + render: (config: Config) => ( + <EuiButtonIcon + aria-label="Delete" + iconType="trash" + onClick={() => setConfigToBeDeleted(config)} + /> + ) + } + ]; + + return ( + <> + {configToBeDeleted && ( + <ConfirmDeleteModal + config={configToBeDeleted} + onCancel={() => setConfigToBeDeleted(null)} + onConfirm={() => { + setConfigToBeDeleted(null); + refetch(); + }} + /> + )} + + <ManagedTable + noItemsMessage={<LoadingStatePrompt />} + columns={columns} + items={data} + initialSortField="service.name" + initialSortDirection="asc" + initialPageSize={20} + /> + </> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx index 35cc68547d337..8171e339adc82 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiTitle, @@ -16,97 +16,59 @@ import { } from '@elastic/eui'; import { isEmpty } from 'lodash'; import { useFetcher } from '../../../../hooks/useFetcher'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { AgentConfigurationListAPIResponse } from '../../../../../../../../plugins/apm/server/lib/settings/agent_configuration/list_configurations'; -import { AgentConfigurationList } from './AgentConfigurationList'; +import { AgentConfigurationList } from './List'; import { useTrackPageview } from '../../../../../../../../plugins/observability/public'; -import { AddEditFlyout } from './AddEditFlyout'; - -export type Config = AgentConfigurationListAPIResponse[0]; +import { createAgentConfigurationHref } from '../../../shared/Links/apm/agentConfigurationLinks'; export function AgentConfigurations() { - const { data = [], status, refetch } = useFetcher( + const { refetch, data = [], status } = useFetcher( callApmApi => - callApmApi({ pathname: `/api/apm/settings/agent-configuration` }), + callApmApi({ pathname: '/api/apm/settings/agent-configuration' }), [], { preservePreviousData: false } ); - const [selectedConfig, setSelectedConfig] = useState<Config | null>(null); - const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); useTrackPageview({ app: 'apm', path: 'agent_configuration' }); useTrackPageview({ app: 'apm', path: 'agent_configuration', delay: 15000 }); const hasConfigurations = !isEmpty(data); - const onClose = () => { - setSelectedConfig(null); - setIsFlyoutOpen(false); - }; - return ( <> - {isFlyoutOpen && ( - <AddEditFlyout - selectedConfig={selectedConfig} - onClose={onClose} - onSaved={() => { - onClose(); - refetch(); - }} - onDeleted={() => { - onClose(); - refetch(); - }} - /> - )} - <EuiPanel> <EuiFlexGroup alignItems="center"> <EuiFlexItem grow={false}> <EuiTitle> <h2> {i18n.translate( - 'xpack.apm.settings.agentConf.configurationsPanelTitle', + 'xpack.apm.agentConfig.configurationsPanelTitle', { defaultMessage: 'Agent remote configuration' } )} </h2> </EuiTitle> </EuiFlexItem> - {hasConfigurations ? ( - <CreateConfigurationButton onClick={() => setIsFlyoutOpen(true)} /> - ) : null} + {hasConfigurations ? <CreateConfigurationButton /> : null} </EuiFlexGroup> <EuiSpacer size="m" /> - <AgentConfigurationList - status={status} - data={data} - setIsFlyoutOpen={setIsFlyoutOpen} - setSelectedConfig={setSelectedConfig} - /> + <AgentConfigurationList status={status} data={data} refetch={refetch} /> </EuiPanel> </> ); } -function CreateConfigurationButton({ onClick }: { onClick: () => void }) { +function CreateConfigurationButton() { + const href = createAgentConfigurationHref(); return ( <EuiFlexItem> <EuiFlexGroup alignItems="center" justifyContent="flexEnd"> <EuiFlexItem grow={false}> - <EuiButton - color="primary" - fill - iconType="plusInCircle" - onClick={onClick} - > - {i18n.translate( - 'xpack.apm.settings.agentConf.createConfigButtonLabel', - { defaultMessage: 'Create configuration' } - )} + <EuiButton color="primary" fill iconType="plusInCircle" href={href}> + {i18n.translate('xpack.apm.agentConfig.createConfigButtonLabel', { + defaultMessage: 'Create configuration' + })} </EuiButton> </EuiFlexItem> </EuiFlexGroup> diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx index eef386731c5c3..f33bb17decd4e 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx @@ -39,23 +39,20 @@ export const Settings: React.FC = props => { id: 0, items: [ { - name: i18n.translate( - 'xpack.apm.settings.agentConfiguration', - { - defaultMessage: 'Agent Configuration' - } - ), + name: i18n.translate('xpack.apm.settings.agentConfig', { + defaultMessage: 'Agent Configuration' + }), id: '1', - // @ts-ignore href: getAPMHref('/settings/agent-configuration', search), - isSelected: pathname === '/settings/agent-configuration' + isSelected: pathname.startsWith( + '/settings/agent-configuration' + ) }, { name: i18n.translate('xpack.apm.settings.indices', { defaultMessage: 'Indices' }), id: '2', - // @ts-ignore href: getAPMHref('/settings/apm-indices', search), isSelected: pathname === '/settings/apm-indices' }, @@ -64,7 +61,6 @@ export const Settings: React.FC = props => { defaultMessage: 'Customize UI' }), id: '3', - // @ts-ignore href: getAPMHref('/settings/customize-ui', search), isSelected: pathname === '/settings/customize-ui' } diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.tsx index eba59f6e3ce44..29b3fff2050c8 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.tsx @@ -31,7 +31,7 @@ export const PERSISTENT_APM_PARAMS = [ export function getAPMHref( path: string, - currentSearch: string, // TODO: Replace with passing in URL PARAMS here + currentSearch: string, query: APMQueryParams = {} ) { const currentQuery = toQuery(currentSearch); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx new file mode 100644 index 0000000000000..0c747e0773a69 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx @@ -0,0 +1,26 @@ +/* + * 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 { getAPMHref } from './APMLink'; +import { AgentConfigurationIntake } from '../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { history } from '../../../../utils/history'; + +export function editAgentConfigurationHref( + configService: AgentConfigurationIntake['service'] +) { + const { search } = history.location; + return getAPMHref('/settings/agent-configuration/edit', search, { + // ignoring because `name` has not been added to url params. Related: https://github.com/elastic/kibana/issues/51963 + // @ts-ignore + name: configService.name, + environment: configService.environment + }); +} + +export function createAgentConfigurationHref() { + const { search } = history.location; + return getAPMHref('/settings/agent-configuration/create', search); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx index a8e6bc0a648af..8698978bfe6fb 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx @@ -7,33 +7,38 @@ import React from 'react'; import { EuiSelect } from '@elastic/eui'; import { isEmpty } from 'lodash'; +import { i18n } from '@kbn/i18n'; -const NO_SELECTION = 'NO_SELECTION'; +export const NO_SELECTION = '__NO_SELECTION__'; +const DEFAULT_PLACEHOLDER = i18n.translate('xpack.apm.selectPlaceholder', { + defaultMessage: 'Select option:' +}); /** * This component addresses some cross-browser inconsistencies of `EuiSelect` * with `hasNoInitialSelection`. It uses the `placeholder` prop to populate * the first option as the initial, not selected option. */ -export const SelectWithPlaceholder: typeof EuiSelect = props => ( - <EuiSelect - {...props} - options={[ - { text: props.placeholder, value: NO_SELECTION }, - ...(props.options || []) - ]} - value={isEmpty(props.value) ? NO_SELECTION : props.value} - onChange={e => { - if (props.onChange) { - props.onChange( - Object.assign(e, { +export const SelectWithPlaceholder: typeof EuiSelect = props => { + const placeholder = props.placeholder || DEFAULT_PLACEHOLDER; + return ( + <EuiSelect + {...props} + options={[ + { text: placeholder, value: NO_SELECTION }, + ...(props.options || []) + ]} + value={isEmpty(props.value) ? NO_SELECTION : props.value} + onChange={e => { + if (props.onChange) { + const customEvent = Object.assign(e, { target: Object.assign(e.target, { - value: - e.target.value === NO_SELECTION ? undefined : e.target.value + value: e.target.value === NO_SELECTION ? '' : e.target.value }) - }) - ); - } - }} - /> -); + }); + props.onChange(customEvent); + } + }} + /> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/context/LocationContext.tsx b/x-pack/legacy/plugins/apm/public/context/LocationContext.tsx index 30e6e02c9fbc1..1e9c20494b42e 100644 --- a/x-pack/legacy/plugins/apm/public/context/LocationContext.tsx +++ b/x-pack/legacy/plugins/apm/public/context/LocationContext.tsx @@ -11,10 +11,8 @@ import { withRouter } from 'react-router-dom'; const initialLocation = {} as Location; const LocationContext = createContext(initialLocation); -const LocationProvider: React.ComponentClass<{}> = withRouter( - ({ location, children }) => { - return <LocationContext.Provider children={children} value={location} />; - } -); +const LocationProvider = withRouter(({ location, children }) => { + return <LocationContext.Provider children={children} value={location} />; +}); export { LocationContext, LocationProvider }; diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx index c2530d6982c3b..95cebd6b2a465 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx +++ b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable no-console */ + import React, { useContext, useEffect, useState, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { IHttpFetchError } from 'src/core/public'; @@ -20,7 +22,7 @@ export enum FETCH_STATUS { PENDING = 'pending' } -interface Result<Data> { +export interface FetcherResult<Data> { data?: Data; status: FETCH_STATUS; error?: Error; @@ -40,13 +42,15 @@ export function useFetcher<TReturn>( options: { preservePreviousData?: boolean; } = {} -): Result<InferResponseType<TReturn>> & { refetch: () => void } { +): FetcherResult<InferResponseType<TReturn>> & { refetch: () => void } { const { notifications } = useApmPluginContext().core; const { preservePreviousData = true } = options; const { setIsLoading } = useLoadingIndicator(); const { dispatchStatus } = useContext(LoadingIndicatorContext); - const [result, setResult] = useState<Result<InferResponseType<TReturn>>>({ + const [result, setResult] = useState< + FetcherResult<InferResponseType<TReturn>> + >({ data: undefined, status: FETCH_STATUS.PENDING }); @@ -80,11 +84,27 @@ export function useFetcher<TReturn>( data, status: FETCH_STATUS.SUCCESS, error: undefined - } as Result<InferResponseType<TReturn>>); + } as FetcherResult<InferResponseType<TReturn>>); } } catch (e) { - const err = e as IHttpFetchError; + const err = e as Error | IHttpFetchError; + if (!didCancel) { + const errorDetails = + 'response' in err ? ( + <> + {err.response?.statusText} ({err.response?.status}) + <h5> + {i18n.translate('xpack.apm.fetcher.error.url', { + defaultMessage: `URL` + })} + </h5> + {err.response?.url} + </> + ) : ( + err.message + ); + notifications.toasts.addWarning({ title: i18n.translate('xpack.apm.fetcher.error.title', { defaultMessage: `Error while fetching resource` @@ -96,13 +116,8 @@ export function useFetcher<TReturn>( defaultMessage: `Error` })} </h5> - {err.response?.statusText} ({err.response?.status}) - <h5> - {i18n.translate('xpack.apm.fetcher.error.url', { - defaultMessage: `URL` - })} - </h5> - {err.response?.url} + + {errorDetails} </div> ) }); diff --git a/x-pack/plugins/apm/common/agent_configuration_constants.ts b/x-pack/plugins/apm/common/agent_configuration/all_option.ts similarity index 74% rename from x-pack/plugins/apm/common/agent_configuration_constants.ts rename to x-pack/plugins/apm/common/agent_configuration/all_option.ts index 4ddc65c14a134..7b1c7bdda97a7 100644 --- a/x-pack/plugins/apm/common/agent_configuration_constants.ts +++ b/x-pack/plugins/apm/common/agent_configuration/all_option.ts @@ -8,11 +8,11 @@ import { i18n } from '@kbn/i18n'; export const ALL_OPTION_VALUE = 'ALL_OPTION_VALUE'; -// human-readable label for the option. The "All" option should be translated. +// human-readable label for service and environment. The "All" option should be translated. // Everything else should be returned verbatim export function getOptionLabel(value: string | undefined) { if (value === undefined || value === ALL_OPTION_VALUE) { - return i18n.translate('xpack.apm.settings.agentConf.allOptionLabel', { + return i18n.translate('xpack.apm.agentConfig.allOptionLabel', { defaultMessage: 'All' }); } @@ -20,6 +20,6 @@ export function getOptionLabel(value: string | undefined) { return value; } -export function omitAllOption(value: string) { +export function omitAllOption(value?: string) { return value === ALL_OPTION_VALUE ? undefined : value; } diff --git a/x-pack/plugins/apm/common/agent_configuration/amount_and_unit.ts b/x-pack/plugins/apm/common/agent_configuration/amount_and_unit.ts new file mode 100644 index 0000000000000..447e529c9c199 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/amount_and_unit.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +interface AmountAndUnit { + amount: string; + unit: string; +} + +export function amountAndUnitToObject(value: string): AmountAndUnit { + const [, amount = '', unit = ''] = value.match(/(\d+)?(\w+)?/) || []; + return { amount, unit }; +} + +export function amountAndUnitToString({ amount, unit }: AmountAndUnit) { + return `${amount}${unit}`; +} diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/configuration_types.d.ts b/x-pack/plugins/apm/common/agent_configuration/configuration_types.d.ts similarity index 82% rename from x-pack/plugins/apm/server/lib/settings/agent_configuration/configuration_types.d.ts rename to x-pack/plugins/apm/common/agent_configuration/configuration_types.d.ts index ddbe6892c5441..675298c3719f2 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/configuration_types.d.ts +++ b/x-pack/plugins/apm/common/agent_configuration/configuration_types.d.ts @@ -5,11 +5,12 @@ */ import t from 'io-ts'; -import { agentConfigurationIntakeRt } from '../../../../common/runtime_types/agent_configuration_intake_rt'; +import { agentConfigurationIntakeRt } from './runtime_types/agent_configuration_intake_rt'; export type AgentConfigurationIntake = t.TypeOf< typeof agentConfigurationIntakeRt >; + export type AgentConfiguration = { '@timestamp': number; applied_by_agent?: boolean; diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.test.ts new file mode 100644 index 0000000000000..c2a3be784f661 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { agentConfigurationIntakeRt } from './agent_configuration_intake_rt'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('agentConfigurationIntakeRt', () => { + it('is valid when "transaction_sample_rate" is string', () => { + const config = { + service: { name: 'my-service', environment: 'my-environment' }, + settings: { + transaction_sample_rate: '0.5' + } + }; + + expect(isConfigValid(config)).toBe(true); + }); + + it('is invalid when "transaction_sample_rate" is number', () => { + const config = { + service: {}, + settings: { + transaction_sample_rate: 0.5 + } + }; + + expect(isConfigValid(config)).toBe(false); + }); + + it('is valid when unknown setting is string', () => { + const config = { + service: { name: 'my-service', environment: 'my-environment' }, + settings: { + my_unknown_setting: '0.5' + } + }; + + expect(isConfigValid(config)).toBe(true); + }); + + it('is invalid when unknown setting is boolean', () => { + const config = { + service: { name: 'my-service', environment: 'my-environment' }, + settings: { + my_unknown_setting: false + } + }; + + expect(isConfigValid(config)).toBe(false); + }); +}); + +function isConfigValid(config: any) { + return isRight(agentConfigurationIntakeRt.decode(config)); +} diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.ts new file mode 100644 index 0000000000000..a0b1d5015b9ef --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { settingDefinitions } from '../setting_definitions'; + +// retrieve validation from config definitions settings and validate on the server +const knownSettings = settingDefinitions.reduce< + // TODO: is it possible to get rid of any? + Record<string, t.Type<any, string, unknown>> +>((acc, { key, validation }) => { + acc[key] = validation; + return acc; +}, {}); + +export const serviceRt = t.partial({ + name: t.string, + environment: t.string +}); + +export const settingsRt = t.intersection([ + t.record(t.string, t.string), + t.partial(knownSettings) +]); + +export const agentConfigurationIntakeRt = t.intersection([ + t.partial({ agent_name: t.string }), + t.type({ + service: serviceRt, + settings: settingsRt + }) +]); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/boolean_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/boolean_rt.test.ts new file mode 100644 index 0000000000000..d4dfbec2344fe --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/boolean_rt.test.ts @@ -0,0 +1,26 @@ +/* + * 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 { booleanRt } from './boolean_rt'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('booleanRt', () => { + describe('it should not accept', () => { + [undefined, null, '', 0, 'foo', true, false].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(booleanRt.decode(input))).toBe(false); + }); + }); + }); + + describe('it should accept', () => { + ['true', 'false'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(booleanRt.decode(input))).toBe(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/boolean_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/boolean_rt.ts new file mode 100644 index 0000000000000..0cd3ac9d6939c --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/boolean_rt.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; + +export const booleanRt = t.union([t.literal('true'), t.literal('false')]); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.test.ts new file mode 100644 index 0000000000000..596037645c002 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.test.ts @@ -0,0 +1,39 @@ +/* + * 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 { bytesRt } from './bytes_rt'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('bytesRt', () => { + describe('it should not accept', () => { + [ + undefined, + null, + '', + 0, + 'foo', + true, + false, + '100', + 'mb', + '0kb', + '5gb', + '6tb' + ].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(bytesRt.decode(input))).toBe(false); + }); + }); + }); + + describe('it should accept', () => { + ['1b', '2kb', '3mb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(bytesRt.decode(input))).toBe(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.ts new file mode 100644 index 0000000000000..d189fab89ae5d --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.ts @@ -0,0 +1,33 @@ +/* + * 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 * as t from 'io-ts'; +import { either } from 'fp-ts/lib/Either'; +import { amountAndUnitToObject } from '../amount_and_unit'; + +export const BYTE_UNITS = ['b', 'kb', 'mb']; + +export const bytesRt = new t.Type<string, string, unknown>( + 'bytesRt', + t.string.is, + (input, context) => { + return either.chain(t.string.validate(input, context), inputAsString => { + const { amount, unit } = amountAndUnitToObject(inputAsString); + const amountAsInt = parseInt(amount, 10); + const isValidUnit = BYTE_UNITS.includes(unit); + const isValid = amountAsInt > 0 && isValidUnit; + + return isValid + ? t.success(inputAsString) + : t.failure( + input, + context, + `Must have numeric amount and a valid unit (${BYTE_UNITS})` + ); + }); + }, + t.identity +); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/capture_body_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/capture_body_rt.test.ts new file mode 100644 index 0000000000000..ba522e6b8ea68 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/capture_body_rt.test.ts @@ -0,0 +1,26 @@ +/* + * 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 { captureBodyRt } from './capture_body_rt'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('captureBodyRt', () => { + describe('it should not accept', () => { + [undefined, null, '', 0, 'foo', true, false].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(captureBodyRt.decode(input))).toBe(false); + }); + }); + }); + + describe('it should accept', () => { + ['off', 'errors', 'transactions', 'all'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(captureBodyRt.decode(input))).toBe(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/capture_body_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/capture_body_rt.ts new file mode 100644 index 0000000000000..8f38a8976beb0 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/capture_body_rt.ts @@ -0,0 +1,14 @@ +/* + * 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 * as t from 'io-ts'; + +export const captureBodyRt = t.union([ + t.literal('off'), + t.literal('errors'), + t.literal('transactions'), + t.literal('all') +]); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.test.ts new file mode 100644 index 0000000000000..6b81031542c34 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.test.ts @@ -0,0 +1,34 @@ +/* + * 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. + */ + +/* + * 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 { durationRt } from './duration_rt'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('durationRt', () => { + describe('it should not accept', () => { + [undefined, null, '', 0, 'foo', true, false, '100', 's', 'm', '0h'].map( + input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(durationRt.decode(input))).toBe(false); + }); + } + ); + }); + + describe('It should accept', () => { + ['1s', '2m', '3h'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(durationRt.decode(input))).toBe(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.ts new file mode 100644 index 0000000000000..99e6a57089dee --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.ts @@ -0,0 +1,33 @@ +/* + * 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 * as t from 'io-ts'; +import { either } from 'fp-ts/lib/Either'; +import { amountAndUnitToObject } from '../amount_and_unit'; + +export const DURATION_UNITS = ['s', 'm', 'h']; + +export const durationRt = new t.Type<string, string, unknown>( + 'durationRt', + t.string.is, + (input, context) => { + return either.chain(t.string.validate(input, context), inputAsString => { + const { amount, unit } = amountAndUnitToObject(inputAsString); + const amountAsInt = parseInt(amount, 10); + const isValidUnit = DURATION_UNITS.includes(unit); + const isValid = amountAsInt > 0 && isValidUnit; + + return isValid + ? t.success(inputAsString) + : t.failure( + input, + context, + `Must have numeric amount and a valid unit (${DURATION_UNITS})` + ); + }); + }, + t.identity +); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.test.ts new file mode 100644 index 0000000000000..ef7fbeed4331e --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.test.ts @@ -0,0 +1,47 @@ +/* + * 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 { integerRt, getIntegerRt } from './integer_rt'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('integerRt', () => { + describe('it should not accept', () => { + [undefined, null, '', 'foo', 0, 55, NaN].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(integerRt.decode(input))).toBe(false); + }); + }); + }); + + describe('it should accept', () => { + ['-1234', '-1', '0', '1000', '32000', '100000'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(integerRt.decode(input))).toBe(true); + }); + }); + }); +}); + +describe('getIntegerRt', () => { + const customIntegerRt = getIntegerRt({ min: 0, max: 32000 }); + describe('it should not accept', () => { + [undefined, null, '', 'foo', 0, 55, '-1', '-55', '33000', NaN].map( + input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(customIntegerRt.decode(input))).toBe(false); + }); + } + ); + }); + + describe('it should accept', () => { + ['0', '1000', '32000'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(customIntegerRt.decode(input))).toBe(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.ts new file mode 100644 index 0000000000000..6dbf175c8b4ce --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.ts @@ -0,0 +1,31 @@ +/* + * 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 * as t from 'io-ts'; +import { either } from 'fp-ts/lib/Either'; + +export function getIntegerRt({ min, max }: { min: number; max: number }) { + return new t.Type<string, string, unknown>( + 'integerRt', + t.string.is, + (input, context) => { + return either.chain(t.string.validate(input, context), inputAsString => { + const inputAsInt = parseInt(inputAsString, 10); + const isValid = inputAsInt >= min && inputAsInt <= max; + return isValid + ? t.success(inputAsString) + : t.failure( + input, + context, + `Number must be a valid number between ${min} and ${max}` + ); + }); + }, + t.identity + ); +} + +export const integerRt = getIntegerRt({ min: -Infinity, max: Infinity }); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.test.ts new file mode 100644 index 0000000000000..ece229ca162fb --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { numberFloatRt } from './number_float_rt'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('numberFloatRt', () => { + it('does not accept empty values', () => { + expect(isRight(numberFloatRt.decode(undefined))).toBe(false); + expect(isRight(numberFloatRt.decode(null))).toBe(false); + expect(isRight(numberFloatRt.decode(''))).toBe(false); + }); + + it('should only accept stringified numbers', () => { + expect(isRight(numberFloatRt.decode('0.5'))).toBe(true); + expect(isRight(numberFloatRt.decode(0.5))).toBe(false); + }); + + it('checks if the number falls within 0, 1', () => { + expect(isRight(numberFloatRt.decode('0'))).toBe(true); + expect(isRight(numberFloatRt.decode('0.5'))).toBe(true); + expect(isRight(numberFloatRt.decode('-0.1'))).toBe(false); + expect(isRight(numberFloatRt.decode('1.1'))).toBe(false); + expect(isRight(numberFloatRt.decode(NaN))).toBe(false); + }); + + it('checks whether the number of decimals is 3', () => { + expect(isRight(numberFloatRt.decode('1'))).toBe(true); + expect(isRight(numberFloatRt.decode('0.9'))).toBe(true); + expect(isRight(numberFloatRt.decode('0.99'))).toBe(true); + expect(isRight(numberFloatRt.decode('0.999'))).toBe(true); + expect(isRight(numberFloatRt.decode('0.9999'))).toBe(false); + }); +}); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.ts new file mode 100644 index 0000000000000..f1890c9851a3d --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.ts @@ -0,0 +1,36 @@ +/* + * 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 * as t from 'io-ts'; +import { either } from 'fp-ts/lib/Either'; + +export function getNumberFloatRt({ min, max }: { min: number; max: number }) { + return new t.Type<string, string, unknown>( + 'numberFloatRt', + t.string.is, + (input, context) => { + return either.chain(t.string.validate(input, context), inputAsString => { + const inputAsFloat = parseFloat(inputAsString); + const maxThreeDecimals = + parseFloat(inputAsFloat.toFixed(3)) === inputAsFloat; + + const isValid = + inputAsFloat >= min && inputAsFloat <= max && maxThreeDecimals; + + return isValid + ? t.success(inputAsString) + : t.failure( + input, + context, + `Number must be between ${min} and ${max}` + ); + }); + }, + t.identity + ); +} + +export const numberFloatRt = getNumberFloatRt({ min: 0, max: 1 }); diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap new file mode 100644 index 0000000000000..365d8838a24a6 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap @@ -0,0 +1,191 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`settingDefinitions should have correct default values 1`] = ` +Array [ + Object { + "key": "active", + "type": "boolean", + "validationName": "(\\"true\\" | \\"false\\")", + }, + Object { + "key": "api_request_size", + "type": "bytes", + "units": Array [ + "b", + "kb", + "mb", + ], + "validationError": "Please specify an integer and a unit", + "validationName": "bytesRt", + }, + Object { + "key": "api_request_time", + "type": "duration", + "units": Array [ + "s", + "m", + "h", + ], + "validationError": "Please specify an integer and a unit", + "validationName": "durationRt", + }, + Object { + "key": "capture_body", + "options": Array [ + Object { + "text": "off", + }, + Object { + "text": "errors", + }, + Object { + "text": "transactions", + }, + Object { + "text": "all", + }, + ], + "type": "select", + "validationName": "(\\"off\\" | \\"errors\\" | \\"transactions\\" | \\"all\\")", + }, + Object { + "key": "capture_headers", + "type": "boolean", + "validationName": "(\\"true\\" | \\"false\\")", + }, + Object { + "key": "circuit_breaker_enabled", + "type": "boolean", + "validationName": "(\\"true\\" | \\"false\\")", + }, + Object { + "key": "enable_log_correlation", + "type": "boolean", + "validationName": "(\\"true\\" | \\"false\\")", + }, + Object { + "key": "log_level", + "type": "text", + "validationName": "string", + }, + Object { + "key": "profiling_inferred_spans_enabled", + "type": "boolean", + "validationName": "(\\"true\\" | \\"false\\")", + }, + Object { + "key": "profiling_inferred_spans_excluded_classes", + "type": "text", + "validationName": "string", + }, + Object { + "key": "profiling_inferred_spans_included_classes", + "type": "text", + "validationName": "string", + }, + Object { + "key": "profiling_inferred_spans_min_duration", + "type": "duration", + "units": Array [ + "s", + "m", + "h", + ], + "validationError": "Please specify an integer and a unit", + "validationName": "durationRt", + }, + Object { + "key": "profiling_inferred_spans_sampling_interval", + "type": "duration", + "units": Array [ + "s", + "m", + "h", + ], + "validationError": "Please specify an integer and a unit", + "validationName": "durationRt", + }, + Object { + "key": "server_timeout", + "type": "duration", + "units": Array [ + "s", + "m", + "h", + ], + "validationError": "Please specify an integer and a unit", + "validationName": "durationRt", + }, + Object { + "key": "span_frames_min_duration", + "type": "duration", + "units": Array [ + "s", + "m", + "h", + ], + "validationError": "Please specify an integer and a unit", + "validationName": "durationRt", + }, + Object { + "key": "stack_trace_limit", + "type": "integer", + "validationError": "Must be an integer", + "validationName": "integerRt", + }, + Object { + "key": "stress_monitor_cpu_duration_threshold", + "type": "duration", + "units": Array [ + "s", + "m", + "h", + ], + "validationError": "Please specify an integer and a unit", + "validationName": "durationRt", + }, + Object { + "key": "stress_monitor_gc_relief_threshold", + "type": "float", + "validationError": "Must be a number between 0.000 and 1", + "validationName": "numberFloatRt", + }, + Object { + "key": "stress_monitor_gc_stress_threshold", + "type": "boolean", + "validationName": "(\\"true\\" | \\"false\\")", + }, + Object { + "key": "stress_monitor_system_cpu_relief_threshold", + "type": "float", + "validationError": "Must be a number between 0.000 and 1", + "validationName": "numberFloatRt", + }, + Object { + "key": "stress_monitor_system_cpu_stress_threshold", + "type": "float", + "validationError": "Must be a number between 0.000 and 1", + "validationName": "numberFloatRt", + }, + Object { + "key": "trace_methods_duration_threshold", + "type": "integer", + "validationError": "Must be an integer", + "validationName": "integerRt", + }, + Object { + "key": "transaction_max_spans", + "max": 32000, + "min": 0, + "type": "integer", + "validationError": "Must be between 0 and 32000", + "validationName": "integerRt", + }, + Object { + "key": "transaction_sample_rate", + "type": "float", + "validationError": "Must be a number between 0.000 and 1", + "validationName": "numberFloatRt", + }, +] +`; diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts new file mode 100644 index 0000000000000..b6eb40305dae7 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -0,0 +1,219 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { getIntegerRt } from '../runtime_types/integer_rt'; +import { captureBodyRt } from '../runtime_types/capture_body_rt'; +import { RawSettingDefinition } from './types'; + +/* + * Settings added here will show up in the UI and will be validated on the client and server + */ +export const generalSettings: RawSettingDefinition[] = [ + // Active + { + key: 'active', + type: 'boolean', + defaultValue: 'true', + label: i18n.translate('xpack.apm.agentConfig.active.label', { + defaultMessage: 'Active' + }), + description: i18n.translate('xpack.apm.agentConfig.active.description', { + defaultMessage: + 'A boolean specifying if the agent should be active or not.\nWhen active, the agent instruments incoming HTTP requests, tracks errors and collects and sends metrics.\nWhen inactive, the agent works as a noop, not collecting data and not communicating with the APM Server.\nAs this is a reversible switch, agent threads are not being killed when inactivated, but they will be \nmostly idle in this state, so the overhead should be negligible.\n\nYou can use this setting to dynamically disable Elastic APM at runtime.' + }), + excludeAgents: ['js-base', 'rum-js', 'python', 'dotnet'] + }, + + // API Request Size + { + key: 'api_request_size', + type: 'bytes', + defaultValue: '768kb', + label: i18n.translate('xpack.apm.agentConfig.apiRequestSize.label', { + defaultMessage: 'API Request Size' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.apiRequestSize.description', + { + defaultMessage: + 'The maximum total compressed size of the request body which is sent to the APM server intake api via a chunked encoding (HTTP streaming).\nNote that a small overshoot is possible.\n\nAllowed byte units are `b`, `kb` and `mb`. `1kb` is equal to `1024b`.' + } + ), + excludeAgents: ['js-base', 'rum-js', 'dotnet'] + }, + + // API Request Time + { + key: 'api_request_time', + type: 'duration', + defaultValue: '10s', + label: i18n.translate('xpack.apm.agentConfig.apiRequestTime.label', { + defaultMessage: 'API Request Time' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.apiRequestTime.description', + { + defaultMessage: + "Maximum time to keep an HTTP request to the APM Server open for.\n\nNOTE: This value has to be lower than the APM Server's `read_timeout` setting." + } + ), + excludeAgents: ['js-base', 'rum-js', 'dotnet'] + }, + + // Capture headers + { + key: 'capture_headers', + type: 'boolean', + defaultValue: 'true', + label: i18n.translate('xpack.apm.agentConfig.captureHeaders.label', { + defaultMessage: 'Capture Headers' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.captureHeaders.description', + { + defaultMessage: + 'If set to `true`, the agent will capture request and response headers, including cookies.\n\nNOTE: Setting this to `false` reduces network bandwidth, disk space and object allocations.' + } + ), + excludeAgents: ['js-base', 'rum-js'] + }, + + // Capture body + { + key: 'capture_body', + validation: captureBodyRt, + type: 'select', + defaultValue: 'off', + label: i18n.translate('xpack.apm.agentConfig.captureBody.label', { + defaultMessage: 'Capture body' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.captureBody.description', + { + defaultMessage: + 'For transactions that are HTTP requests, the agent can optionally capture the request body (e.g. POST variables). Default is "off".' + } + ), + options: [ + { text: 'off' }, + { text: 'errors' }, + { text: 'transactions' }, + { text: 'all' } + ], + excludeAgents: ['js-base', 'rum-js', 'dotnet'] + }, + + // LOG_LEVEL + { + key: 'log_level', + type: 'text', + defaultValue: 'info', + label: i18n.translate('xpack.apm.agentConfig.logLevel.label', { + defaultMessage: 'Log level' + }), + description: i18n.translate('xpack.apm.agentConfig.logLevel.description', { + defaultMessage: 'Sets the logging level for the agent' + }), + excludeAgents: ['js-base', 'rum-js', 'python'] + }, + + // SERVER_TIMEOUT + { + key: 'server_timeout', + type: 'duration', + defaultValue: '5s', + label: i18n.translate('xpack.apm.agentConfig.serverTimeout.label', { + defaultMessage: 'Server Timeout' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.serverTimeout.description', + { + defaultMessage: + 'If a request to the APM server takes longer than the configured timeout,\nthe request is cancelled and the event (exception or transaction) is discarded.\nSet to 0 to disable timeouts.\n\nWARNING: If timeouts are disabled or set to a high value, your app could experience memory issues if the APM server times out.' + } + ), + includeAgents: ['nodejs', 'java', 'go'] + }, + + // SPAN_FRAMES_MIN_DURATION + { + key: 'span_frames_min_duration', + type: 'duration', + defaultValue: '5ms', + label: i18n.translate('xpack.apm.agentConfig.spanFramesMinDuration.label', { + defaultMessage: 'Span frames minimum duration' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.spanFramesMinDuration.description', + { + defaultMessage: + 'In its default settings, the APM agent will collect a stack trace with every recorded span.\nWhile this is very helpful to find the exact place in your code that causes the span, collecting this stack trace does have some overhead. \nWhen setting this option to a negative value, like `-1ms`, stack traces will be collected for all spans. Setting it to a positive value, e.g. `5ms`, will limit stack trace collection to spans with durations equal to or longer than the given value, e.g. 5 milliseconds.\n\nTo disable stack trace collection for spans completely, set the value to `0ms`.' + } + ), + excludeAgents: ['js-base', 'rum-js', 'nodejs'] + }, + + // STACK_TRACE_LIMIT + { + key: 'stack_trace_limit', + type: 'integer', + defaultValue: '50', + label: i18n.translate('xpack.apm.agentConfig.stackTraceLimit.label', { + defaultMessage: 'Stack trace limit' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.stackTraceLimit.description', + { + defaultMessage: + 'Setting it to 0 will disable stack trace collection. Any positive integer value will be used as the maximum number of frames to collect. Setting it -1 means that all frames will be collected.' + } + ), + includeAgents: ['nodejs', 'java', 'dotnet', 'go'] + }, + + // Transaction sample rate + { + key: 'transaction_sample_rate', + type: 'float', + defaultValue: '1.0', + label: i18n.translate('xpack.apm.agentConfig.transactionSampleRate.label', { + defaultMessage: 'Transaction sample rate' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.transactionSampleRate.description', + { + defaultMessage: + 'By default, the agent will sample every transaction (e.g. request to your service). To reduce overhead and storage requirements, you can set the sample rate to a value between 0.0 and 1.0. We still record overall time and the result for unsampled transactions, but no context information, labels, or spans.' + } + ) + }, + + // Transaction max spans + { + key: 'transaction_max_spans', + type: 'integer', + validation: getIntegerRt({ min: 0, max: 32000 }), + validationError: i18n.translate( + 'xpack.apm.agentConfig.transactionMaxSpans.errorText', + { defaultMessage: 'Must be between 0 and 32000' } + ), + defaultValue: '500', + label: i18n.translate('xpack.apm.agentConfig.transactionMaxSpans.label', { + defaultMessage: 'Transaction max spans' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.transactionMaxSpans.description', + { + defaultMessage: + 'Limits the amount of spans that are recorded per transaction. Default is 500.' + } + ), + min: 0, + max: 32000, + excludeAgents: ['js-base', 'rum-js'] + } +]; diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts new file mode 100644 index 0000000000000..0d1113d74c98b --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts @@ -0,0 +1,187 @@ +/* + * 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 { omit } from 'lodash'; +import { filterByAgent, settingDefinitions } from '../setting_definitions'; +import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; +import { SettingDefinition } from './types'; + +describe('filterByAgent', () => { + describe('when `excludeAgents` is dotnet and nodejs', () => { + const setting = { + key: 'my-setting', + excludeAgents: ['dotnet', 'nodejs'] + } as SettingDefinition; + + it('should not include dotnet', () => { + expect(filterByAgent('dotnet')(setting)).toBe(false); + }); + + it('should include go', () => { + expect(filterByAgent('go')(setting)).toBe(true); + }); + }); + + describe('when `includeAgents` is dotnet and nodejs', () => { + const setting = { + key: 'my-setting', + includeAgents: ['dotnet', 'nodejs'] + } as SettingDefinition; + + it('should not include go', () => { + expect(filterByAgent('go')(setting)).toBe(false); + }); + + it('should include dotnet', () => { + expect(filterByAgent('dotnet')(setting)).toBe(true); + }); + }); + + describe('options per agent', () => { + it('go', () => { + expect(getSettingKeysForAgent('go')).toEqual([ + 'active', + 'api_request_size', + 'api_request_time', + 'capture_body', + 'capture_headers', + 'log_level', + 'server_timeout', + 'span_frames_min_duration', + 'stack_trace_limit', + 'transaction_max_spans', + 'transaction_sample_rate' + ]); + }); + + it('java', () => { + expect(getSettingKeysForAgent('java')).toEqual([ + 'active', + 'api_request_size', + 'api_request_time', + 'capture_body', + 'capture_headers', + 'circuit_breaker_enabled', + 'enable_log_correlation', + 'log_level', + 'profiling_inferred_spans_enabled', + 'profiling_inferred_spans_excluded_classes', + 'profiling_inferred_spans_included_classes', + 'profiling_inferred_spans_min_duration', + 'profiling_inferred_spans_sampling_interval', + 'server_timeout', + 'span_frames_min_duration', + 'stack_trace_limit', + 'stress_monitor_cpu_duration_threshold', + 'stress_monitor_gc_relief_threshold', + 'stress_monitor_gc_stress_threshold', + 'stress_monitor_system_cpu_relief_threshold', + 'stress_monitor_system_cpu_stress_threshold', + 'trace_methods_duration_threshold', + 'transaction_max_spans', + 'transaction_sample_rate' + ]); + }); + + it('js-base', () => { + expect(getSettingKeysForAgent('js-base')).toEqual([ + 'transaction_sample_rate' + ]); + }); + + it('rum-js', () => { + expect(getSettingKeysForAgent('rum-js')).toEqual([ + 'transaction_sample_rate' + ]); + }); + + it('nodejs', () => { + expect(getSettingKeysForAgent('nodejs')).toEqual([ + 'active', + 'api_request_size', + 'api_request_time', + 'capture_body', + 'capture_headers', + 'log_level', + 'server_timeout', + 'stack_trace_limit', + 'transaction_max_spans', + 'transaction_sample_rate' + ]); + }); + + it('python', () => { + expect(getSettingKeysForAgent('python')).toEqual([ + 'api_request_size', + 'api_request_time', + 'capture_body', + 'capture_headers', + 'span_frames_min_duration', + 'transaction_max_spans', + 'transaction_sample_rate' + ]); + }); + + it('dotnet', () => { + expect(getSettingKeysForAgent('dotnet')).toEqual([ + 'capture_headers', + 'log_level', + 'span_frames_min_duration', + 'stack_trace_limit', + 'transaction_max_spans', + 'transaction_sample_rate' + ]); + }); + + it('ruby', () => { + expect(getSettingKeysForAgent('ruby')).toEqual([ + 'active', + 'api_request_size', + 'api_request_time', + 'capture_body', + 'capture_headers', + 'log_level', + 'span_frames_min_duration', + 'transaction_max_spans', + 'transaction_sample_rate' + ]); + }); + + it('"All" services (no agent name)', () => { + expect(getSettingKeysForAgent(undefined)).toEqual([ + 'capture_headers', + 'transaction_max_spans', + 'transaction_sample_rate' + ]); + }); + }); +}); + +describe('settingDefinitions', () => { + it('should have correct default values', () => { + expect( + settingDefinitions.map(def => { + return { + ...omit(def, [ + 'category', + 'defaultValue', + 'description', + 'excludeAgents', + 'includeAgents', + 'label', + 'validation' + ]), + validationName: def.validation.name + }; + }) + ).toMatchSnapshot(); + }); +}); + +function getSettingKeysForAgent(agentName: AgentName | undefined) { + const definitions = settingDefinitions.filter(filterByAgent(agentName)); + return definitions.map(def => def.key); +} diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.ts new file mode 100644 index 0000000000000..8786a94be096d --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.ts @@ -0,0 +1,113 @@ +/* + * 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 * as t from 'io-ts'; +import { sortBy } from 'lodash'; +import { isRight } from 'fp-ts/lib/Either'; +import { i18n } from '@kbn/i18n'; +import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; +import { booleanRt } from '../runtime_types/boolean_rt'; +import { integerRt } from '../runtime_types/integer_rt'; +import { isRumAgentName } from '../../agent_name'; +import { numberFloatRt } from '../runtime_types/number_float_rt'; +import { bytesRt, BYTE_UNITS } from '../runtime_types/bytes_rt'; +import { durationRt, DURATION_UNITS } from '../runtime_types/duration_rt'; +import { RawSettingDefinition, SettingDefinition } from './types'; +import { generalSettings } from './general_settings'; +import { javaSettings } from './java_settings'; + +function getDefaultsByType(settingDefinition: RawSettingDefinition) { + switch (settingDefinition.type) { + case 'boolean': + return { validation: booleanRt }; + case 'text': + return { validation: t.string }; + case 'integer': + return { + validation: integerRt, + validationError: i18n.translate( + 'xpack.apm.agentConfig.integer.errorText', + { defaultMessage: 'Must be an integer' } + ) + }; + case 'float': + return { + validation: numberFloatRt, + validationError: i18n.translate( + 'xpack.apm.agentConfig.float.errorText', + { defaultMessage: 'Must be a number between 0.000 and 1' } + ) + }; + case 'bytes': + return { + validation: bytesRt, + units: BYTE_UNITS, + validationError: i18n.translate( + 'xpack.apm.agentConfig.bytes.errorText', + { defaultMessage: 'Please specify an integer and a unit' } + ) + }; + case 'duration': + return { + validation: durationRt, + units: DURATION_UNITS, + validationError: i18n.translate( + 'xpack.apm.agentConfig.bytes.errorText', + { defaultMessage: 'Please specify an integer and a unit' } + ) + }; + } +} + +export function filterByAgent(agentName?: AgentName) { + return (setting: SettingDefinition) => { + // agentName is missing if "All" was selected + if (!agentName) { + // options that only apply to certain agents will be filtered out + if (setting.includeAgents) { + return false; + } + + // only options that apply to every agent (ignoring RUM) should be returned + if (setting.excludeAgents) { + return setting.excludeAgents.every(isRumAgentName); + } + + return true; + } + + if (setting.includeAgents) { + return setting.includeAgents.includes(agentName); + } + + if (setting.excludeAgents) { + return !setting.excludeAgents.includes(agentName); + } + + return true; + }; +} + +export function isValid(setting: SettingDefinition, value: unknown) { + return isRight(setting.validation.decode(value)); +} + +export const settingDefinitions = sortBy( + [...generalSettings, ...javaSettings].map(def => { + const defWithDefaults = { + ...getDefaultsByType(def), + ...def + }; + + // ensure every option has validation + if (!defWithDefaults.validation) { + throw new Error(`Missing validation for ${def.key}`); + } + + return defWithDefaults as SettingDefinition; + }), + 'key' +); diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/java_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/java_settings.ts new file mode 100644 index 0000000000000..1a480c131e853 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/java_settings.ts @@ -0,0 +1,256 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { RawSettingDefinition } from './types'; + +export const javaSettings: RawSettingDefinition[] = [ + // ENABLE_LOG_CORRELATION + { + key: 'enable_log_correlation', + type: 'boolean', + defaultValue: 'false', + label: i18n.translate('xpack.apm.agentConfig.enableLogCorrelation.label', { + defaultMessage: 'Enable log correlation' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.enableLogCorrelation.description', + { + defaultMessage: + "A boolean specifying if the agent should integrate into SLF4J's https://www.slf4j.org/api/org/slf4j/MDC.html[MDC] to enable trace-log correlation.\nIf set to `true`, the agent will set the `trace.id` and `transaction.id` for the currently active spans and transactions to the MDC.\nSee <<log-correlation>> for more details.\n\nNOTE: While it's allowed to enable this setting at runtime, you can't disable it without a restart." + } + ), + includeAgents: ['java'] + }, + + // TRACE_METHODS_DURATION_THRESHOLD + { + key: 'trace_methods_duration_threshold', + type: 'integer', + label: i18n.translate( + 'xpack.apm.agentConfig.traceMethodsDurationThreshold.label', + { + defaultMessage: 'Trace methods duration threshold' + } + ), + description: i18n.translate( + 'xpack.apm.agentConfig.traceMethodsDurationThreshold.description', + { + defaultMessage: + 'If trace_methods config option is set, provides a threshold to limit spans based on duration. When set to a value greater than 0, spans representing methods traced based on trace_methods will be discarded by default.' + } + ), + includeAgents: ['java'] + }, + + /* + * Circuit-Breaker + **/ + { + key: 'circuit_breaker_enabled', + label: i18n.translate('xpack.apm.agentConfig.circuitBreakerEnabled.label', { + defaultMessage: 'Cirtcuit breaker enabled' + }), + type: 'boolean', + category: 'Circuit-Breaker', + defaultValue: 'false', + description: i18n.translate( + 'xpack.apm.agentConfig.circuitBreakerEnabled.description', + { + defaultMessage: + 'A boolean specifying whether the circuit breaker should be enabled or not. \nWhen enabled, the agent periodically polls stress monitors to detect system/process/JVM stress state. \nIf ANY of the monitors detects a stress indication, the agent will become inactive, as if the \n<<config-active>> configuration option has been set to `false`, thus reducing resource consumption to a minimum. \nWhen inactive, the agent continues polling the same monitors in order to detect whether the stress state \nhas been relieved. If ALL monitors approve that the system/process/JVM is not under stress anymore, the \nagent will resume and become fully functional.' + } + ), + includeAgents: ['java'] + }, + { + key: 'stress_monitor_gc_stress_threshold', + label: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorGcStressThreshold.label', + { defaultMessage: 'Stress monitor gc stress threshold' } + ), + type: 'boolean', + category: 'Circuit-Breaker', + defaultValue: '0.95', + description: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorGcStressThreshold.description', + { + defaultMessage: + 'The threshold used by the GC monitor to rely on for identifying heap stress.\nThe same threshold will be used for all heap pools, so that if ANY has a usage percentage that crosses it, \nthe agent will consider it as a heap stress. The GC monitor relies only on memory consumption measured \nafter a recent GC.' + } + ), + includeAgents: ['java'] + }, + { + key: 'stress_monitor_gc_relief_threshold', + label: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorGcReliefThreshold.label', + { defaultMessage: 'Stress monitor gc relief threshold' } + ), + + type: 'float', + category: 'Circuit-Breaker', + defaultValue: '0.75', + description: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorGcReliefThreshold.description', + { + defaultMessage: + 'The threshold used by the GC monitor to rely on for identifying when the heap is not under stress .\nIf `stress_monitor_gc_stress_threshold` has been crossed, the agent will consider it a heap-stress state. \nIn order to determine that the stress state is over, percentage of occupied memory in ALL heap pools should \nbe lower than this threshold. The GC monitor relies only on memory consumption measured after a recent GC.' + } + ), + includeAgents: ['java'] + }, + { + key: 'stress_monitor_cpu_duration_threshold', + label: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorCpuDurationThreshold.label', + { defaultMessage: 'Stress monitor cpu duration threshold' } + ), + type: 'duration', + category: 'Circuit-Breaker', + defaultValue: '1m', + description: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorCpuDurationThreshold.description', + { + defaultMessage: + 'The minimal time required in order to determine whether the system is \neither currently under stress, or that the stress detected previously has been relieved. \nAll measurements during this time must be consistent in comparison to the relevant threshold in \norder to detect a change of stress state. Must be at least `1m`.' + } + ), + includeAgents: ['java'] + }, + { + key: 'stress_monitor_system_cpu_stress_threshold', + label: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorSystemCpuStressThreshold.label', + { defaultMessage: 'Stress monitor system cpu stress threshold' } + ), + type: 'float', + category: 'Circuit-Breaker', + defaultValue: '0.95', + description: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorSystemCpuStressThreshold.description', + { + defaultMessage: + 'The threshold used by the system CPU monitor to detect system CPU stress. \nIf the system CPU crosses this threshold for a duration of at least `stress_monitor_cpu_duration_threshold`, \nthe monitor considers this as a stress state.' + } + ), + includeAgents: ['java'] + }, + { + key: 'stress_monitor_system_cpu_relief_threshold', + label: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorSystemCpuReliefThreshold.label', + { defaultMessage: 'Stress monitor system cpu relief threshold' } + ), + type: 'float', + category: 'Circuit-Breaker', + defaultValue: '0.8', + description: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorSystemCpuReliefThreshold.description', + { + defaultMessage: + 'The threshold used by the system CPU monitor to determine that the system is \nnot under CPU stress. If the monitor detected a CPU stress, the measured system CPU needs to be below \nthis threshold for a duration of at least `stress_monitor_cpu_duration_threshold` in order for the \nmonitor to decide that the CPU stress has been relieved.' + } + ), + includeAgents: ['java'] + }, + + /* + * Profiling + **/ + + { + key: 'profiling_inferred_spans_enabled', + label: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansEnabled.label', + { defaultMessage: 'Profiling inferred spans enabled' } + ), + type: 'boolean', + category: 'Profiling', + defaultValue: 'false', + description: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansEnabled.description', + { + defaultMessage: + 'Set to `true` to make the agent create spans for method executions based on\nhttps://github.com/jvm-profiling-tools/async-profiler[async-profiler], a sampling aka statistical profiler.\n\nDue to the nature of how sampling profilers work,\nthe duration of the inferred spans are not exact, but only estimations.\nThe <<config-profiling-inferred-spans-sampling-interval, `profiling_inferred_spans_sampling_interval`>> lets you fine tune the trade-off between accuracy and overhead.\n\nThe inferred spans are created after a profiling session has ended.\nThis means there is a delay between the regular and the inferred spans being visible in the UI.\n\nNOTE: This feature is not available on Windows' + } + ), + includeAgents: ['java'] + }, + { + key: 'profiling_inferred_spans_sampling_interval', + label: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansSamplingInterval.label', + { defaultMessage: 'Profiling inferred spans sampling interval' } + ), + type: 'duration', + category: 'Profiling', + defaultValue: '50ms', + description: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansSamplingInterval.description', + { + defaultMessage: + 'The frequency at which stack traces are gathered within a profiling session.\nThe lower you set it, the more accurate the durations will be.\nThis comes at the expense of higher overhead and more spans for potentially irrelevant operations.\nThe minimal duration of a profiling-inferred span is the same as the value of this setting.' + } + ), + includeAgents: ['java'] + }, + { + key: 'profiling_inferred_spans_min_duration', + label: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansMinDuration.label', + { defaultMessage: 'Profiling inferred spans min duration' } + ), + type: 'duration', + category: 'Profiling', + defaultValue: '0ms', + description: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansMinDuration.description', + { + defaultMessage: + 'The minimum duration of an inferred span.\nNote that the min duration is also implicitly set by the sampling interval.\nHowever, increasing the sampling interval also decreases the accuracy of the duration of inferred spans.' + } + ), + includeAgents: ['java'] + }, + { + key: 'profiling_inferred_spans_included_classes', + label: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansIncludedClasses.label', + { defaultMessage: 'Profiling inferred spans included classes' } + ), + type: 'text', + category: 'Profiling', + defaultValue: '*', + description: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansIncludedClasses.description', + { + defaultMessage: + 'If set, the agent will only create inferred spans for methods which match this list.\nSetting a value may slightly increase performance and can reduce clutter by only creating spans for the classes you are interested in.\nExample: `org.example.myapp.*`\n\nThis option supports the wildcard `*`, which matches zero or more characters.\nExamples: `/foo/*/bar/*/baz*`, `*foo*`.\nMatching is case insensitive by default.\nPrepending an element with `(?-i)` makes the matching case sensitive.' + } + ), + includeAgents: ['java'] + }, + { + key: 'profiling_inferred_spans_excluded_classes', + label: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansExcludedClasses.label', + { defaultMessage: 'Profiling inferred spans excluded classes' } + ), + type: 'text', + category: 'Profiling', + defaultValue: + '(?-i)java.*,(?-i)javax.*,(?-i)sun.*,(?-i)com.sun.*,(?-i)jdk.*,(?-i)org.apache.tomcat.*,(?-i)org.apache.catalina.*,(?-i)org.apache.coyote.*,(?-i)org.jboss.as.*,(?-i)org.glassfish.*,(?-i)org.eclipse.jetty.*,(?-i)com.ibm.websphere.*,(?-i)io.undertow.*', + description: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansExcludedClasses.description', + { + defaultMessage: + 'Excludes classes for which no profiler-inferred spans should be created.\n\nThis option supports the wildcard `*`, which matches zero or more characters.\nExamples: `/foo/*/bar/*/baz*`, `*foo*`.\nMatching is case insensitive by default.\nPrepending an element with `(?-i)` makes the matching case sensitive.' + } + ), + includeAgents: ['java'] + } +]; diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts new file mode 100644 index 0000000000000..6b584fc7e2048 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts @@ -0,0 +1,107 @@ +/* + * 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 * as t from 'io-ts'; +import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; + +interface BaseSetting { + /** + * UI: unique key to identify setting + */ + key: string; + + /** + * UI: Human readable name of setting + */ + label: string; + + /** + * UI: Human readable name of setting + * Not used yet + */ + category?: string; + + /** + * UI: + */ + defaultValue?: string; + + /** + * UI: description of setting + */ + description: string; + + /** + * UI: placeholder to show in input field + */ + placeholder?: string; + + /** + * runtime validation of the input + */ + validation?: t.Type<any, string, unknown>; + + /** + * UI: error shown when the runtime validation fails + */ + validationError?: string; + + /** + * Limits the setting to no agents, except those specified in `includeAgents` + */ + includeAgents?: AgentName[]; + + /** + * Limits the setting to all agents, except those specified in `excludeAgents` + */ + excludeAgents?: AgentName[]; +} + +interface TextSetting extends BaseSetting { + type: 'text'; +} + +interface IntegerSetting extends BaseSetting { + type: 'integer'; + min?: number; + max?: number; +} + +interface FloatSetting extends BaseSetting { + type: 'float'; +} + +interface SelectSetting extends BaseSetting { + type: 'select'; + options: Array<{ text: string }>; +} + +interface BooleanSetting extends BaseSetting { + type: 'boolean'; +} + +interface BytesSetting extends BaseSetting { + type: 'bytes'; + units?: string[]; +} + +interface DurationSetting extends BaseSetting { + type: 'duration'; + units?: string[]; +} + +export type RawSettingDefinition = + | TextSetting + | FloatSetting + | IntegerSetting + | SelectSetting + | BooleanSetting + | BytesSetting + | DurationSetting; + +export type SettingDefinition = RawSettingDefinition & { + validation: NonNullable<RawSettingDefinition['validation']>; +}; diff --git a/x-pack/plugins/apm/common/runtime_types/agent_configuration_intake_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/agent_configuration_intake_rt/index.test.ts deleted file mode 100644 index 4c9dc78eb41e9..0000000000000 --- a/x-pack/plugins/apm/common/runtime_types/agent_configuration_intake_rt/index.test.ts +++ /dev/null @@ -1,41 +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 { agentConfigurationIntakeRt } from './index'; -import { isRight } from 'fp-ts/lib/Either'; - -describe('agentConfigurationIntakeRt', () => { - it('is valid when required parameters are given', () => { - const config = { - service: {}, - settings: {} - }; - - expect(isConfigValid(config)).toBe(true); - }); - - it('is valid when required and optional parameters are given', () => { - const config = { - service: { name: 'my-service', environment: 'my-environment' }, - settings: { - transaction_sample_rate: 0.5, - capture_body: 'foo', - transaction_max_spans: 10 - } - }; - - expect(isConfigValid(config)).toBe(true); - }); - - it('is invalid when required parameters are not given', () => { - const config = {}; - expect(isConfigValid(config)).toBe(false); - }); -}); - -function isConfigValid(config: any) { - return isRight(agentConfigurationIntakeRt.decode(config)); -} diff --git a/x-pack/plugins/apm/common/runtime_types/agent_configuration_intake_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/agent_configuration_intake_rt/index.ts deleted file mode 100644 index 32a2832b5eaf3..0000000000000 --- a/x-pack/plugins/apm/common/runtime_types/agent_configuration_intake_rt/index.ts +++ /dev/null @@ -1,26 +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 * as t from 'io-ts'; -import { transactionSampleRateRt } from '../transaction_sample_rate_rt'; -import { transactionMaxSpansRt } from '../transaction_max_spans_rt'; - -export const serviceRt = t.partial({ - name: t.string, - environment: t.string -}); - -export const agentConfigurationIntakeRt = t.intersection([ - t.partial({ agent_name: t.string }), - t.type({ - service: serviceRt, - settings: t.partial({ - transaction_sample_rate: transactionSampleRateRt, - capture_body: t.string, - transaction_max_spans: transactionMaxSpansRt - }) - }) -]); diff --git a/x-pack/plugins/apm/common/runtime_types/transaction_max_spans_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/transaction_max_spans_rt/index.test.ts deleted file mode 100644 index b62251b6974d9..0000000000000 --- a/x-pack/plugins/apm/common/runtime_types/transaction_max_spans_rt/index.test.ts +++ /dev/null @@ -1,28 +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 { transactionMaxSpansRt } from './index'; -import { isRight } from 'fp-ts/lib/Either'; - -describe('transactionMaxSpans', () => { - it('does not accept empty values', () => { - expect(isRight(transactionMaxSpansRt.decode(undefined))).toBe(false); - expect(isRight(transactionMaxSpansRt.decode(null))).toBe(false); - expect(isRight(transactionMaxSpansRt.decode(''))).toBe(false); - }); - - it('accepts both strings and numbers as values', () => { - expect(isRight(transactionMaxSpansRt.decode('55'))).toBe(true); - expect(isRight(transactionMaxSpansRt.decode(55))).toBe(true); - }); - - it('checks if the number falls within 0, 32000', () => { - expect(isRight(transactionMaxSpansRt.decode(0))).toBe(true); - expect(isRight(transactionMaxSpansRt.decode(32000))).toBe(true); - expect(isRight(transactionMaxSpansRt.decode(-55))).toBe(false); - expect(isRight(transactionMaxSpansRt.decode(NaN))).toBe(false); - }); -}); diff --git a/x-pack/plugins/apm/common/runtime_types/transaction_max_spans_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/transaction_max_spans_rt/index.ts deleted file mode 100644 index 251161c21babe..0000000000000 --- a/x-pack/plugins/apm/common/runtime_types/transaction_max_spans_rt/index.ts +++ /dev/null @@ -1,19 +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 * as t from 'io-ts'; - -export const transactionMaxSpansRt = new t.Type<number, number, unknown>( - 'transactionMaxSpans', - t.number.is, - (input, context) => { - const value = parseInt(input as string, 10); - return value >= 0 && value <= 32000 - ? t.success(value) - : t.failure(input, context); - }, - t.identity -); diff --git a/x-pack/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.test.ts deleted file mode 100644 index 6930a69f0870a..0000000000000 --- a/x-pack/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.test.ts +++ /dev/null @@ -1,38 +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 { transactionSampleRateRt } from './index'; -import { isRight } from 'fp-ts/lib/Either'; - -describe('transactionSampleRateRt', () => { - it('does not accept empty values', () => { - expect(isRight(transactionSampleRateRt.decode(undefined))).toBe(false); - expect(isRight(transactionSampleRateRt.decode(null))).toBe(false); - expect(isRight(transactionSampleRateRt.decode(''))).toBe(false); - }); - - it('accepts both strings and numbers as values', () => { - expect(isRight(transactionSampleRateRt.decode('0.5'))).toBe(true); - expect(isRight(transactionSampleRateRt.decode(0.5))).toBe(true); - }); - - it('checks if the number falls within 0, 1', () => { - expect(isRight(transactionSampleRateRt.decode(0))).toBe(true); - - expect(isRight(transactionSampleRateRt.decode(0.5))).toBe(true); - - expect(isRight(transactionSampleRateRt.decode(-0.1))).toBe(false); - expect(isRight(transactionSampleRateRt.decode(1.1))).toBe(false); - - expect(isRight(transactionSampleRateRt.decode(NaN))).toBe(false); - }); - - it('checks whether the number of decimals is 3', () => { - expect(isRight(transactionSampleRateRt.decode(1))).toBe(true); - expect(isRight(transactionSampleRateRt.decode(0.99))).toBe(true); - expect(isRight(transactionSampleRateRt.decode(0.999))).toBe(true); - expect(isRight(transactionSampleRateRt.decode(0.998))).toBe(true); - }); -}); diff --git a/x-pack/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.ts deleted file mode 100644 index 90c60d16f7b59..0000000000000 --- a/x-pack/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.ts +++ /dev/null @@ -1,19 +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 * as t from 'io-ts'; - -export const transactionSampleRateRt = new t.Type<number, number, unknown>( - 'TransactionSampleRate', - t.number.is, - (input, context) => { - const value = parseFloat(input as string); - return value >= 0 && value <= 1 && parseFloat(value.toFixed(3)) === value - ? t.success(value) - : t.failure(input, context); - }, - t.identity -); diff --git a/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts b/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts index cc01c990bf985..91a595c0900be 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts @@ -9,8 +9,9 @@ import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; export type Mappings = | { - dynamic?: boolean; + dynamic?: boolean | 'strict'; properties: Record<string, Mappings>; + dynamic_templates?: any[]; } | { type: string; diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/convert_settings_to_string.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/convert_settings_to_string.ts new file mode 100644 index 0000000000000..ab01a68733a7a --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/convert_settings_to_string.ts @@ -0,0 +1,26 @@ +/* + * 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 { ESSearchHit } from '../../../../typings/elasticsearch'; +import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; + +// needed for backwards compatability +// All settings except `transaction_sample_rate` and `transaction_max_spans` are stored as strings (they are stored as float and integer respectively) +export function convertConfigSettingsToString( + hit: ESSearchHit<AgentConfiguration> +) { + const config = hit._source; + + if (config.settings?.transaction_sample_rate) { + config.settings.transaction_sample_rate = config.settings.transaction_sample_rate.toString(); + } + + if (config.settings?.transaction_max_spans) { + config.settings.transaction_max_spans = config.settings.transaction_max_spans.toString(); + } + + return hit; +} diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts index bc03138e0c247..b2dc22ceb2918 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts @@ -26,6 +26,19 @@ export async function createApmAgentConfigurationIndex({ } const mappings: Mappings = { + dynamic: 'strict', + dynamic_templates: [ + { + // force string to keyword (instead of default of text + keyword) + strings: { + match_mapping_type: 'string', + mapping: { + type: 'keyword', + ignore_above: 1024 + } + } + } + ], properties: { '@timestamp': { type: 'date' @@ -43,21 +56,9 @@ const mappings: Mappings = { } }, settings: { - properties: { - transaction_sample_rate: { - type: 'scaled_float', - scaling_factor: 1000, - ignore_malformed: true, - coerce: false - }, - capture_body: { - type: 'keyword', - ignore_above: 1024 - }, - transaction_max_spans: { - type: 'short' - } - } + // allowing dynamic fields without specifying anything specific + dynamic: true, + properties: {} }, applied_by_agent: { type: 'boolean' diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_or_update_configuration.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_or_update_configuration.ts index 74fcc61dde863..aa5ddb288b2d7 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_or_update_configuration.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_or_update_configuration.ts @@ -9,7 +9,7 @@ import { Setup } from '../../helpers/setup_request'; import { AgentConfiguration, AgentConfigurationIntake -} from './configuration_types'; +} from '../../../../common/agent_configuration/configuration_types'; import { APMIndexDocumentParams } from '../../helpers/es_client'; export async function createOrUpdateConfiguration({ diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts index eea409882f876..e3455cdbc5a1c 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts @@ -9,8 +9,9 @@ import { SERVICE_ENVIRONMENT } from '../../../../common/elasticsearch_fieldnames'; import { Setup } from '../../helpers/setup_request'; -import { AgentConfiguration } from './configuration_types'; +import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; import { ESSearchHit } from '../../../../typings/elasticsearch'; +import { convertConfigSettingsToString } from './convert_settings_to_string'; export async function findExactConfiguration({ service, @@ -42,5 +43,11 @@ export async function findExactConfiguration({ params ); - return resp.hits.hits[0] as ESSearchHit<AgentConfiguration> | undefined; + const hit = resp.hits.hits[0] as ESSearchHit<AgentConfiguration> | undefined; + + if (!hit) { + return; + } + + return convertConfigSettingsToString(hit); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_all_environments.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_all_environments.ts index 52a3422f8e6b7..a70f7965d0e59 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_all_environments.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_all_environments.ts @@ -10,7 +10,7 @@ import { SERVICE_NAME, SERVICE_ENVIRONMENT } from '../../../../../common/elasticsearch_fieldnames'; -import { ALL_OPTION_VALUE } from '../../../../../common/agent_configuration_constants'; +import { ALL_OPTION_VALUE } from '../../../../../common/agent_configuration/all_option'; export async function getAllEnvironments({ serviceName, diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts index f54217461510f..754c698af8753 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts @@ -9,7 +9,7 @@ import { SERVICE_NAME, SERVICE_ENVIRONMENT } from '../../../../../common/elasticsearch_fieldnames'; -import { ALL_OPTION_VALUE } from '../../../../../common/agent_configuration_constants'; +import { ALL_OPTION_VALUE } from '../../../../../common/agent_configuration/all_option'; export async function getExistingEnvironmentsForService({ serviceName, diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts index 9b9acbb1e0ad5..352bbe1b6a294 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts @@ -10,7 +10,7 @@ import { PROCESSOR_EVENT, SERVICE_NAME } from '../../../../common/elasticsearch_fieldnames'; -import { ALL_OPTION_VALUE } from '../../../../common/agent_configuration_constants'; +import { ALL_OPTION_VALUE } from '../../../../common/agent_configuration/all_option'; export type AgentConfigurationServicesAPIResponse = PromiseReturnType< typeof getServiceNames diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts index 585de740bc88d..c44d7b41f532b 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts @@ -6,7 +6,8 @@ import { PromiseReturnType } from '../../../../typings/common'; import { Setup } from '../../helpers/setup_request'; -import { AgentConfiguration } from './configuration_types'; +import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; +import { convertConfigSettingsToString } from './convert_settings_to_string'; export type AgentConfigurationListAPIResponse = PromiseReturnType< typeof listConfigurations @@ -20,8 +21,7 @@ export async function listConfigurations({ setup }: { setup: Setup }) { }; const resp = await internalClient.search<AgentConfiguration>(params); - return resp.hits.hits.map(item => ({ - id: item._id, - ...item._source - })); + return resp.hits.hits + .map(convertConfigSettingsToString) + .map(hit => hit._source); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/mark_applied_by_agent.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/mark_applied_by_agent.ts index b6aecd1d7f0ca..5157c94a74768 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/mark_applied_by_agent.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/mark_applied_by_agent.ts @@ -5,7 +5,7 @@ */ import { Setup } from '../../helpers/setup_request'; -import { AgentConfiguration } from './configuration_types'; +import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; export async function markAppliedByAgent({ id, diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts index 9bbdc96a3a797..fa38e8fabfb69 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts @@ -9,7 +9,8 @@ import { SERVICE_ENVIRONMENT } from '../../../../common/elasticsearch_fieldnames'; import { Setup } from '../../helpers/setup_request'; -import { AgentConfiguration } from './configuration_types'; +import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; +import { convertConfigSettingsToString } from './convert_settings_to_string'; export async function searchConfigurations({ service, @@ -66,5 +67,11 @@ export async function searchConfigurations({ params ); - return resp.hits.hits[0] as ESSearchHit<AgentConfiguration> | undefined; + const hit = resp.hits.hits[0] as ESSearchHit<AgentConfiguration> | undefined; + + if (!hit) { + return; + } + + return convertConfigSettingsToString(hit); } diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts index 1583e15bdecd5..42b99b34beea7 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts @@ -26,6 +26,7 @@ export const createApmCustomLinkIndex = async ({ }; const mappings: Mappings = { + dynamic: 'strict', properties: { '@timestamp': { type: 'date' diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 50a794067bfad..57b3f282852c4 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -22,6 +22,7 @@ import { } from './services'; import { agentConfigurationRoute, + getSingleAgentConfigurationRoute, agentConfigurationSearchRoute, deleteAgentConfigurationRoute, listAgentConfigurationEnvironmentsRoute, @@ -86,6 +87,7 @@ const createApmApi = () => { .add(serviceAnnotationsRoute) // Agent configuration + .add(getSingleAgentConfigurationRoute) .add(agentConfigurationAgentNameRoute) .add(agentConfigurationRoute) .add(agentConfigurationSearchRoute) diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts index 83b845b1fc436..8cfd736a336c2 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts @@ -20,7 +20,7 @@ import { markAppliedByAgent } from '../../lib/settings/agent_configuration/mark_ import { serviceRt, agentConfigurationIntakeRt -} from '../../../common/runtime_types/agent_configuration_intake_rt'; +} from '../../../common/agent_configuration/runtime_types/agent_configuration_intake_rt'; import { jsonRt } from '../../../common/runtime_types/json_rt'; // get list of configurations @@ -32,6 +32,31 @@ export const agentConfigurationRoute = createRoute(core => ({ } })); +// get a single configuration +export const getSingleAgentConfigurationRoute = createRoute(() => ({ + path: '/api/apm/settings/agent-configuration/view', + params: { + query: serviceRt + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { name, environment } = context.params.query; + + const service = { name, environment }; + const config = await findExactConfiguration({ service, setup }); + + if (!config) { + context.logger.info( + `Config was not found for ${service.name}/${service.environment}` + ); + + throw Boom.notFound(); + } + + return config._source; + } +})); + // delete configuration export const deleteAgentConfigurationRoute = createRoute(() => ({ method: 'DELETE', @@ -68,45 +93,7 @@ export const deleteAgentConfigurationRoute = createRoute(() => ({ } })); -// get list of services -export const listAgentConfigurationServicesRoute = createRoute(() => ({ - method: 'GET', - path: '/api/apm/settings/agent-configuration/services', - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - return await getServiceNames({ - setup - }); - } -})); - -// get environments for service -export const listAgentConfigurationEnvironmentsRoute = createRoute(() => ({ - path: '/api/apm/settings/agent-configuration/environments', - params: { - query: t.partial({ serviceName: t.string }) - }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.query; - return await getEnvironments({ serviceName, setup }); - } -})); - -// get agentName for service -export const agentConfigurationAgentNameRoute = createRoute(() => ({ - path: '/api/apm/settings/agent-configuration/agent_name', - params: { - query: t.type({ serviceName: t.string }) - }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.query; - const agentName = await getAgentNameByService({ serviceName, setup }); - return { agentName }; - } -})); - +// create/update configuration export const createOrUpdateAgentConfigurationRoute = createRoute(() => ({ method: 'PUT', path: '/api/apm/settings/agent-configuration', @@ -154,10 +141,10 @@ export const agentConfigurationSearchRoute = createRoute(core => ({ method: 'POST', path: '/api/apm/settings/agent-configuration/search', params: { - body: t.type({ - service: serviceRt, - etag: t.string - }) + body: t.intersection([ + t.type({ service: serviceRt }), + t.partial({ etag: t.string }) + ]) }, handler: async ({ context, request }) => { const { service, etag } = context.params.body; @@ -188,3 +175,46 @@ export const agentConfigurationSearchRoute = createRoute(core => ({ return config; } })); + +/* + * Utility endpoints (not documented as part of the public API) + */ + +// get list of services +export const listAgentConfigurationServicesRoute = createRoute(() => ({ + method: 'GET', + path: '/api/apm/settings/agent-configuration/services', + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + return await getServiceNames({ + setup + }); + } +})); + +// get environments for service +export const listAgentConfigurationEnvironmentsRoute = createRoute(() => ({ + path: '/api/apm/settings/agent-configuration/environments', + params: { + query: t.partial({ serviceName: t.string }) + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { serviceName } = context.params.query; + return await getEnvironments({ serviceName, setup }); + } +})); + +// get agentName for service +export const agentConfigurationAgentNameRoute = createRoute(() => ({ + path: '/api/apm/settings/agent-configuration/agent_name', + params: { + query: t.type({ serviceName: t.string }) + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { serviceName } = context.params.query; + const agentName = await getAgentNameByService({ serviceName, setup }); + return { agentName }; + } +})); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index bec3ffd147964..ce78847a8e8b3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3860,48 +3860,6 @@ "xpack.apm.servicesTable.transactionsPerMinuteColumnLabel": "1 分あたりのトランザクション", "xpack.apm.servicesTable.transactionsPerMinuteUnitLabel": "1分あたりトランザクション数", "xpack.apm.servicesTable.UpgradeAssistantLink": "Kibana アップグレードアシスタントで詳細をご覧ください", - "xpack.apm.settings.agentConf.allOptionLabel": "すべて", - "xpack.apm.settings.agentConf.cancelButtonLabel": "キャンセル", - "xpack.apm.settings.agentConf.configTable.appliedTooltipMessage": "1 つまたは複数のエージェントにより適用されました。", - "xpack.apm.settings.agentConf.configTable.captureBodyColumnLabel": "キャプチャ本文", - "xpack.apm.settings.agentConf.configTable.configTable.failurePromptText": "エージェントの構成一覧を取得できませんでした。ユーザーに十分なパーミッションがない可能性があります。", - "xpack.apm.settings.agentConf.configTable.createConfigButtonLabel": "構成の作成", - "xpack.apm.settings.agentConf.configTable.editButtonDescription": "この構成を編集します", - "xpack.apm.settings.agentConf.configTable.editButtonLabel": "編集", - "xpack.apm.settings.agentConf.configTable.emptyPromptText": "変更しましょう。直接 Kibana からエージェント構成を微調整できます。再展開する必要はありません。まず、最初の構成を作成します。", - "xpack.apm.settings.agentConf.configTable.emptyPromptTitle": "構成が見つかりません。", - "xpack.apm.settings.agentConf.configTable.environmentColumnLabel": "サービス環境", - "xpack.apm.settings.agentConf.configTable.lastUpdatedColumnLabel": "最終更新", - "xpack.apm.settings.agentConf.configTable.notAppliedTooltipMessage": "まだエージェントにより適用されていません", - "xpack.apm.settings.agentConf.configTable.sampleRateColumnLabel": "サンプルレート", - "xpack.apm.settings.agentConf.configTable.serviceNameColumnLabel": "サービス名", - "xpack.apm.settings.agentConf.configTable.transactionMaxSpansColumnLabel": "トランザクションの最大範囲", - "xpack.apm.settings.agentConf.configurationsPanelTitle": "構成", - "xpack.apm.settings.agentConf.createConfigButtonLabel": "構成の作成", - "xpack.apm.settings.agentConf.createConfigTitle": "構成の作成", - "xpack.apm.settings.agentConf.editConfigTitle": "構成の編集", - "xpack.apm.settings.agentConf.flyout.deleteSection.buttonLabel": "削除", - "xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigFailedText": "{serviceName} の構成を削除中にエラーが発生しました。エラー「{errorMessage}」", - "xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigFailedTitle": "構成を削除できませんでした", - "xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigSucceededText": "「{serviceName}」の構成が正常に削除されました。エージェントに反映されるまでに少し時間がかかります。", - "xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigSucceededTitle": "構成が削除されました", - "xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputHelpText": "HTTP リクエストのトランザクションの場合、エージェントはリクエスト本文 (POST 変数など) をキャプチャすることができます。デフォルトは「off」です。", - "xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputLabel": "本文をキャプチャ", - "xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputPlaceholderText": "オプションを選択", - "xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputErrorText": "サンプルレートは 0.000 ~ 1 の範囲でなければなりません", - "xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputHelpText": "0.000 ~ 1.0 の範囲のレートを選択してください。デフォルトは 1.0 (トレースの 100%) です。", - "xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputLabel": "トランザクションのサンプルレート", - "xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputPlaceholderText": "サンプルレートを設定", - "xpack.apm.settings.agentConf.flyOut.settingsSection.title": "オプション", - "xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputErrorText": "0 と 32000 の間でなければなりません", - "xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputHelpText": "トランザクションごとに記録される範囲を制限します。デフォルトは 500 です。", - "xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputLabel": "トランザクションの最大範囲", - "xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputPlaceholderText": "トランザクションの最大範囲を設定", - "xpack.apm.settings.agentConf.saveConfig.failed.text": "「{serviceName}」の構成を保存中にエラーが発生しました。エラー「{errorMessage}」", - "xpack.apm.settings.agentConf.saveConfig.failed.title": "構成を保存できませんでした", - "xpack.apm.settings.agentConf.saveConfig.succeeded.text": "「{serviceName}」の構成が保存されました。エージェントに反映されるまでに少し時間がかかります。", - "xpack.apm.settings.agentConf.saveConfig.succeeded.title": "構成が保存されました", - "xpack.apm.settings.agentConf.saveConfigurationButtonLabel": "保存", "xpack.apm.settingsLinkLabel": "設定", "xpack.apm.setupInstructionsButtonLabel": "セットアップの手順", "xpack.apm.stacktraceTab.localVariablesToogleButtonLabel": "ローカル変数", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f472247232cb8..d5d0a2f9e7aff 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3861,48 +3861,6 @@ "xpack.apm.servicesTable.transactionsPerMinuteColumnLabel": "每分钟事务数", "xpack.apm.servicesTable.transactionsPerMinuteUnitLabel": "tpm", "xpack.apm.servicesTable.UpgradeAssistantLink": "通过访问 Kibana 升级助手来了解详情", - "xpack.apm.settings.agentConf.allOptionLabel": "全部", - "xpack.apm.settings.agentConf.cancelButtonLabel": "取消", - "xpack.apm.settings.agentConf.configTable.appliedTooltipMessage": "已至少由一个代理应用", - "xpack.apm.settings.agentConf.configTable.captureBodyColumnLabel": "捕获正文", - "xpack.apm.settings.agentConf.configTable.configTable.failurePromptText": "无法获取代理配置列表。您的用户可能没有足够的权限。", - "xpack.apm.settings.agentConf.configTable.createConfigButtonLabel": "创建配置", - "xpack.apm.settings.agentConf.configTable.editButtonDescription": "编辑此配置", - "xpack.apm.settings.agentConf.configTable.editButtonLabel": "编辑", - "xpack.apm.settings.agentConf.configTable.emptyPromptText": "让我们改动一下!可以直接从 Kibana 微调代理配置,无需重新部署。首先创建您的第一个配置。", - "xpack.apm.settings.agentConf.configTable.emptyPromptTitle": "未找到任何配置。", - "xpack.apm.settings.agentConf.configTable.environmentColumnLabel": "服务环境", - "xpack.apm.settings.agentConf.configTable.lastUpdatedColumnLabel": "最后更新时间", - "xpack.apm.settings.agentConf.configTable.notAppliedTooltipMessage": "尚未由任何代理应用", - "xpack.apm.settings.agentConf.configTable.sampleRateColumnLabel": "采样速率", - "xpack.apm.settings.agentConf.configTable.serviceNameColumnLabel": "服务名称", - "xpack.apm.settings.agentConf.configTable.transactionMaxSpansColumnLabel": "事务最大跨度数", - "xpack.apm.settings.agentConf.configurationsPanelTitle": "代理远程配置", - "xpack.apm.settings.agentConf.createConfigButtonLabel": "创建配置", - "xpack.apm.settings.agentConf.createConfigTitle": "创建配置", - "xpack.apm.settings.agentConf.editConfigTitle": "编辑配置", - "xpack.apm.settings.agentConf.flyout.deleteSection.buttonLabel": "删除", - "xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigFailedText": "为“{serviceName}”删除配置时出现问题。错误:“{errorMessage}”", - "xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigFailedTitle": "配置无法删除", - "xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigSucceededText": "您已成功为“{serviceName}”删除配置。将需要一些时间才能传播到代理。", - "xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigSucceededTitle": "配置已删除", - "xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputHelpText": "有关属于 HTTP 请求的事务,代理可以选择性地捕获请求正文(例如 POST 变量)。默认为“off”。", - "xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputLabel": "捕获正文", - "xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputPlaceholderText": "选择选项", - "xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputErrorText": "采样速率必须介于 0.000 和 1 之间", - "xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputHelpText": "选择 0.000 和 1.0 之间的速率。默认为 1.0(100% 的跟踪)。", - "xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputLabel": "事务采样速率", - "xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputPlaceholderText": "设置采样速率", - "xpack.apm.settings.agentConf.flyOut.settingsSection.title": "选项", - "xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputErrorText": "必须介于 0 和 32000 之间", - "xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputHelpText": "限制每个事务记录的跨度数量。默认值为 500。", - "xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputLabel": "事务最大跨度数", - "xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputPlaceholderText": "设置事务最大跨度数", - "xpack.apm.settings.agentConf.saveConfig.failed.text": "编辑“{serviceName}”的配置时出现问题。错误:“{errorMessage}”", - "xpack.apm.settings.agentConf.saveConfig.failed.title": "配置无法编辑", - "xpack.apm.settings.agentConf.saveConfig.succeeded.text": "“{serviceName}”的配置已保存。将需要一些时间才能传播到代理。", - "xpack.apm.settings.agentConf.saveConfig.succeeded.title": "配置已保存", - "xpack.apm.settings.agentConf.saveConfigurationButtonLabel": "保存", "xpack.apm.settingsLinkLabel": "璁剧疆", "xpack.apm.setupInstructionsButtonLabel": "设置说明", "xpack.apm.stacktraceTab.localVariablesToogleButtonLabel": "本地变量", diff --git a/x-pack/test/api_integration/apis/apm/agent_configuration.ts b/x-pack/test/api_integration/apis/apm/agent_configuration.ts index 959a0c97acfa3..8cabac523791c 100644 --- a/x-pack/test/api_integration/apis/apm/agent_configuration.ts +++ b/x-pack/test/api_integration/apis/apm/agent_configuration.ts @@ -5,7 +5,7 @@ */ import expect from '@kbn/expect'; -import { AgentConfigurationIntake } from '../../../../plugins/apm/server/lib/settings/agent_configuration/configuration_types'; +import { AgentConfigurationIntake } from '../../../../plugins/apm/common/agent_configuration/configuration_types'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function agentConfigurationTests({ getService }: FtrProviderContext) { @@ -70,7 +70,7 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte describe('when creating one configuration', () => { const newConfig = { service: {}, - settings: { transaction_sample_rate: 0.55 }, + settings: { transaction_sample_rate: '0.55' }, }; const searchParams = { @@ -86,16 +86,16 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte const { statusCode, body } = await searchConfigurations(searchParams); expect(statusCode).to.equal(200); expect(body._source.service).to.eql({}); - expect(body._source.settings).to.eql({ transaction_sample_rate: 0.55 }); + expect(body._source.settings).to.eql({ transaction_sample_rate: '0.55' }); }); it('can update the created config', async () => { - await updateConfiguration({ service: {}, settings: { transaction_sample_rate: 0.85 } }); + await updateConfiguration({ service: {}, settings: { transaction_sample_rate: '0.85' } }); const { statusCode, body } = await searchConfigurations(searchParams); expect(statusCode).to.equal(200); expect(body._source.service).to.eql({}); - expect(body._source.settings).to.eql({ transaction_sample_rate: 0.85 }); + expect(body._source.settings).to.eql({ transaction_sample_rate: '0.85' }); }); it('can delete the created config', async () => { @@ -109,23 +109,23 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte const configs = [ { service: {}, - settings: { transaction_sample_rate: 0.1 }, + settings: { transaction_sample_rate: '0.1' }, }, { service: { name: 'my_service' }, - settings: { transaction_sample_rate: 0.2 }, + settings: { transaction_sample_rate: '0.2' }, }, { service: { name: 'my_service', environment: 'development' }, - settings: { transaction_sample_rate: 0.3 }, + settings: { transaction_sample_rate: '0.3' }, }, { service: { environment: 'production' }, - settings: { transaction_sample_rate: 0.4 }, + settings: { transaction_sample_rate: '0.4' }, }, { service: { environment: 'development' }, - settings: { transaction_sample_rate: 0.5 }, + settings: { transaction_sample_rate: '0.5' }, }, ]; @@ -140,27 +140,27 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte const agentsRequests = [ { service: { name: 'non_existing_service', environment: 'non_existing_env' }, - expectedSettings: { transaction_sample_rate: 0.1 }, + expectedSettings: { transaction_sample_rate: '0.1' }, }, { service: { name: 'my_service', environment: 'non_existing_env' }, - expectedSettings: { transaction_sample_rate: 0.2 }, + expectedSettings: { transaction_sample_rate: '0.2' }, }, { service: { name: 'my_service', environment: 'production' }, - expectedSettings: { transaction_sample_rate: 0.2 }, + expectedSettings: { transaction_sample_rate: '0.2' }, }, { service: { name: 'my_service', environment: 'development' }, - expectedSettings: { transaction_sample_rate: 0.3 }, + expectedSettings: { transaction_sample_rate: '0.3' }, }, { service: { name: 'non_existing_service', environment: 'production' }, - expectedSettings: { transaction_sample_rate: 0.4 }, + expectedSettings: { transaction_sample_rate: '0.4' }, }, { service: { name: 'non_existing_service', environment: 'development' }, - expectedSettings: { transaction_sample_rate: 0.5 }, + expectedSettings: { transaction_sample_rate: '0.5' }, }, ]; @@ -180,8 +180,9 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte describe('when an agent retrieves a configuration', () => { const config = { service: { name: 'myservice', environment: 'development' }, - settings: { transaction_sample_rate: 0.9 }, + settings: { transaction_sample_rate: '0.9' }, }; + let etag: string; before(async () => { log.debug('creating agent configuration'); @@ -192,20 +193,27 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte await deleteConfiguration(config); }); - it(`should have 'applied_by_agent=false' on first request`, async () => { - const { body } = await searchConfigurations({ + it(`should have 'applied_by_agent=false' before supplying etag`, async () => { + const res1 = await searchConfigurations({ service: { name: 'myservice', environment: 'development' }, - etag: '7312bdcc34999629a3d39df24ed9b2a7553c0c39', }); - expect(body._source.applied_by_agent).to.be(false); + etag = res1.body._source.etag; + + const res2 = await searchConfigurations({ + service: { name: 'myservice', environment: 'development' }, + etag, + }); + + expect(res1.body._source.applied_by_agent).to.be(false); + expect(res2.body._source.applied_by_agent).to.be(false); }); - it(`should have 'applied_by_agent=true' on second request`, async () => { + it(`should have 'applied_by_agent=true' after supplying etag`, async () => { async function getAppliedByAgent() { const { body } = await searchConfigurations({ service: { name: 'myservice', environment: 'development' }, - etag: '7312bdcc34999629a3d39df24ed9b2a7553c0c39', + etag, }); return body._source.applied_by_agent; diff --git a/x-pack/test/api_integration/apis/apm/feature_controls.ts b/x-pack/test/api_integration/apis/apm/feature_controls.ts index 3c5314d0d3261..afe68f21d9e39 100644 --- a/x-pack/test/api_integration/apis/apm/feature_controls.ts +++ b/x-pack/test/api_integration/apis/apm/feature_controls.ts @@ -244,7 +244,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) describe('apm feature controls', () => { const config = { service: { name: 'test-service' }, - settings: { transaction_sample_rate: 0.5 }, + settings: { transaction_sample_rate: '0.5' }, }; before(async () => { log.info(`Creating agent configuration`); From ef48205f15130f4dd6251a2c3cdf42000becc472 Mon Sep 17 00:00:00 2001 From: Justin Kambic <justin.kambic@elastic.co> Date: Mon, 23 Mar 2020 15:18:11 -0400 Subject: [PATCH 033/179] [Uptime] Add configurable page size to monitor list (#60573) * Add configurable page size to monitor list. * Add functional tests for new feature. * Update outdated snapshots. * Extract UI concerns for size select component to dedicated function. * Add missing props to resolve type check errors. * Add unit test for new UI functionality. * Refresh snapshots after additional changes. * Introduce new parameter to API test function. * Update flex behavior for new UI component. * Clean up code in functional page object file. * Refresh snapshots that were broken by previous feedback implementation. * Fix async error introduced to test framework by other patch. --- .../plugins/uptime/common/graphql/types.ts | 2 + .../__snapshots__/monitor_list.test.tsx.snap | 160 +++++++++++++----- .../monitor_list_pagination.test.tsx.snap | 70 ++++++-- .../__tests__/monitor_list.test.tsx | 6 + .../monitor_list_page_size_select.test.tsx | 45 +++++ .../monitor_list_pagination.test.tsx | 4 + .../functional/monitor_list/monitor_list.tsx | 32 ++-- .../monitor_list_page_size_select.tsx | 132 +++++++++++++++ .../monitor_list/overview_page_link.tsx | 9 +- .../plugins/uptime/public/hooks/index.ts | 2 +- .../uptime/public/hooks/use_url_params.ts | 6 +- .../plugins/uptime/public/pages/overview.tsx | 22 ++- .../public/queries/monitor_states_query.ts | 3 +- .../graphql/monitor_states/resolvers.ts | 3 +- .../graphql/monitor_states/schema.gql.ts | 1 + .../server/lib/requests/get_monitor_states.ts | 5 +- .../apis/uptime/graphql/monitor_states.ts | 1 + .../api_integration/apis/uptime/rest/index.ts | 11 +- .../test/functional/apps/uptime/overview.ts | 90 +++++++++- .../functional/page_objects/uptime_page.ts | 15 +- x-pack/test/functional/services/uptime.ts | 9 + 21 files changed, 533 insertions(+), 95 deletions(-) create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_page_size_select.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_page_size_select.tsx diff --git a/x-pack/legacy/plugins/uptime/common/graphql/types.ts b/x-pack/legacy/plugins/uptime/common/graphql/types.ts index 1a37ce0b18c73..bd017e6cfaf4c 100644 --- a/x-pack/legacy/plugins/uptime/common/graphql/types.ts +++ b/x-pack/legacy/plugins/uptime/common/graphql/types.ts @@ -552,6 +552,8 @@ export interface GetMonitorStatesQueryArgs { filters?: string | null; statusFilter?: string | null; + + pageSize: number; } // ==================================================== diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap index 5f1d790430bdd..2b8bc0bb06ddf 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap @@ -80,25 +80,42 @@ exports[`MonitorList component renders a no items message when no data is provid size="m" /> <EuiFlexGroup + justifyContent="spaceBetween" responsive={false} > <EuiFlexItem grow={false} > - <OverviewPageLink - dataTestSubj="xpack.uptime.monitorList.prevButton" - direction="prev" - pagination="" + <MonitorListPageSizeSelect + setSize={[MockFunction]} + size={25} /> </EuiFlexItem> <EuiFlexItem grow={false} > - <OverviewPageLink - dataTestSubj="xpack.uptime.monitorList.nextButton" - direction="next" - pagination="" - /> + <EuiFlexGroup + responsive={false} + > + <EuiFlexItem + grow={false} + > + <OverviewPageLink + dataTestSubj="xpack.uptime.monitorList.prevButton" + direction="prev" + pagination="" + /> + </EuiFlexItem> + <EuiFlexItem + grow={false} + > + <OverviewPageLink + dataTestSubj="xpack.uptime.monitorList.nextButton" + direction="next" + pagination="" + /> + </EuiFlexItem> + </EuiFlexGroup> </EuiFlexItem> </EuiFlexGroup> </EuiPanel> @@ -110,6 +127,10 @@ exports[`MonitorList component renders the monitor list 1`] = ` padding-left: 17px; } +.c3 { + padding-top: 12px; +} + .c2 { white-space: nowrap; overflow: hidden; @@ -570,41 +591,81 @@ exports[`MonitorList component renders the monitor list 1`] = ` class="euiSpacer euiSpacer--m" /> <div - class="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--directionRow" + class="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow" > <div class="euiFlexItem euiFlexItem--flexGrowZero" > - <button - aria-label="A disabled pagination button indicating that there cannot be any further navigation in the monitors list." - class="euiButtonIcon euiButtonIcon--text" - data-test-subj="xpack.uptime.monitorList.prevButton" - disabled="" - type="button" + <div + class="euiPopover euiPopover--anchorUpLeft" > <div - aria-hidden="true" - class="euiButtonIcon__icon" - data-euiicon-type="arrowLeft" - /> - </button> + class="euiPopover__anchor" + > + <button + class="euiButtonEmpty euiButtonEmpty--text euiButtonEmpty--iconRight" + data-test-subj="xpack.uptime.monitorList.pageSizeSelect.popoverOpen" + type="button" + > + <span + class="euiButtonEmpty__content" + > + <div + aria-hidden="true" + class="euiButtonEmpty__icon" + data-euiicon-type="arrowDown" + /> + <span + class="euiButtonEmpty__text" + > + Rows per page: 25 + </span> + </span> + </button> + </div> + </div> </div> <div class="euiFlexItem euiFlexItem--flexGrowZero" > - <button - aria-label="A disabled pagination button indicating that there cannot be any further navigation in the monitors list." - class="euiButtonIcon euiButtonIcon--text" - data-test-subj="xpack.uptime.monitorList.nextButton" - disabled="" - type="button" + <div + class="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--directionRow" > <div - aria-hidden="true" - class="euiButtonIcon__icon" - data-euiicon-type="arrowRight" - /> - </button> + class="euiFlexItem euiFlexItem--flexGrowZero" + > + <button + aria-label="A disabled pagination button indicating that there cannot be any further navigation in the monitors list." + class="euiButtonIcon euiButtonIcon--text c3" + data-test-subj="xpack.uptime.monitorList.prevButton" + disabled="" + type="button" + > + <div + aria-hidden="true" + class="euiButtonIcon__icon" + data-euiicon-type="arrowLeft" + /> + </button> + </div> + <div + class="euiFlexItem euiFlexItem--flexGrowZero" + > + <button + aria-label="A disabled pagination button indicating that there cannot be any further navigation in the monitors list." + class="euiButtonIcon euiButtonIcon--text c3" + data-test-subj="xpack.uptime.monitorList.nextButton" + disabled="" + type="button" + > + <div + aria-hidden="true" + class="euiButtonIcon__icon" + data-euiicon-type="arrowRight" + /> + </button> + </div> + </div> </div> </div> </div> @@ -752,25 +813,42 @@ exports[`MonitorList component shallow renders the monitor list 1`] = ` size="m" /> <EuiFlexGroup + justifyContent="spaceBetween" responsive={false} > <EuiFlexItem grow={false} > - <OverviewPageLink - dataTestSubj="xpack.uptime.monitorList.prevButton" - direction="prev" - pagination="" + <MonitorListPageSizeSelect + setSize={[MockFunction]} + size={25} /> </EuiFlexItem> <EuiFlexItem grow={false} > - <OverviewPageLink - dataTestSubj="xpack.uptime.monitorList.nextButton" - direction="next" - pagination="" - /> + <EuiFlexGroup + responsive={false} + > + <EuiFlexItem + grow={false} + > + <OverviewPageLink + dataTestSubj="xpack.uptime.monitorList.prevButton" + direction="prev" + pagination="" + /> + </EuiFlexItem> + <EuiFlexItem + grow={false} + > + <OverviewPageLink + dataTestSubj="xpack.uptime.monitorList.nextButton" + direction="next" + pagination="" + /> + </EuiFlexItem> + </EuiFlexGroup> </EuiFlexItem> </EuiFlexGroup> </EuiPanel> diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list_pagination.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list_pagination.test.tsx.snap index 29ab7a8455fe6..db5bfa72deb36 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list_pagination.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list_pagination.test.tsx.snap @@ -80,25 +80,42 @@ exports[`MonitorListPagination component renders a no items message when no data size="m" /> <EuiFlexGroup + justifyContent="spaceBetween" responsive={false} > <EuiFlexItem grow={false} > - <OverviewPageLink - dataTestSubj="xpack.uptime.monitorList.prevButton" - direction="prev" - pagination="" + <MonitorListPageSizeSelect + setSize={[MockFunction]} + size={25} /> </EuiFlexItem> <EuiFlexItem grow={false} > - <OverviewPageLink - dataTestSubj="xpack.uptime.monitorList.nextButton" - direction="next" - pagination="" - /> + <EuiFlexGroup + responsive={false} + > + <EuiFlexItem + grow={false} + > + <OverviewPageLink + dataTestSubj="xpack.uptime.monitorList.prevButton" + direction="prev" + pagination="" + /> + </EuiFlexItem> + <EuiFlexItem + grow={false} + > + <OverviewPageLink + dataTestSubj="xpack.uptime.monitorList.nextButton" + direction="next" + pagination="" + /> + </EuiFlexItem> + </EuiFlexGroup> </EuiFlexItem> </EuiFlexGroup> </EuiPanel> @@ -247,25 +264,42 @@ exports[`MonitorListPagination component renders the monitor list 1`] = ` size="m" /> <EuiFlexGroup + justifyContent="spaceBetween" responsive={false} > <EuiFlexItem grow={false} > - <OverviewPageLink - dataTestSubj="xpack.uptime.monitorList.prevButton" - direction="prev" - pagination="{\\"cursorKey\\":{\\"monitor_id\\":123},\\"cursorDirection\\":\\"BEFORE\\",\\"sortOrder\\":\\"ASC\\"}" + <MonitorListPageSizeSelect + setSize={[MockFunction]} + size={25} /> </EuiFlexItem> <EuiFlexItem grow={false} > - <OverviewPageLink - dataTestSubj="xpack.uptime.monitorList.nextButton" - direction="next" - pagination="{\\"cursorKey\\":{\\"monitor_id\\":456},\\"cursorDirection\\":\\"AFTER\\",\\"sortOrder\\":\\"ASC\\"}" - /> + <EuiFlexGroup + responsive={false} + > + <EuiFlexItem + grow={false} + > + <OverviewPageLink + dataTestSubj="xpack.uptime.monitorList.prevButton" + direction="prev" + pagination="{\\"cursorKey\\":{\\"monitor_id\\":123},\\"cursorDirection\\":\\"BEFORE\\",\\"sortOrder\\":\\"ASC\\"}" + /> + </EuiFlexItem> + <EuiFlexItem + grow={false} + > + <OverviewPageLink + dataTestSubj="xpack.uptime.monitorList.nextButton" + direction="next" + pagination="{\\"cursorKey\\":{\\"monitor_id\\":456},\\"cursorDirection\\":\\"AFTER\\",\\"sortOrder\\":\\"ASC\\"}" + /> + </EuiFlexItem> + </EuiFlexGroup> </EuiFlexItem> </EuiFlexGroup> </EuiPanel> diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list.test.tsx index 5cdd1772a7f24..d2030155d0092 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list.test.tsx @@ -87,6 +87,8 @@ describe('MonitorList component', () => { data={{ monitorStates: result }} hasActiveFilters={false} loading={false} + pageSize={25} + setPageSize={jest.fn()} successColor="primary" /> ); @@ -101,6 +103,8 @@ describe('MonitorList component', () => { data={{}} hasActiveFilters={false} loading={false} + pageSize={25} + setPageSize={jest.fn()} successColor="primary" /> ); @@ -114,6 +118,8 @@ describe('MonitorList component', () => { data={{ monitorStates: result }} hasActiveFilters={false} loading={false} + pageSize={25} + setPageSize={jest.fn()} successColor="primary" /> ); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_page_size_select.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_page_size_select.test.tsx new file mode 100644 index 0000000000000..0642712d951fe --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_page_size_select.test.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { MonitorListPageSizeSelectComponent } from '../monitor_list_page_size_select'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; + +describe('MonitorListPageSizeSelect', () => { + it('updates the state when selection changes', () => { + const setSize = jest.fn(); + const setUrlParams = jest.fn(); + const wrapper = mountWithIntl( + <MonitorListPageSizeSelectComponent size={10} setSize={setSize} setUrlParams={setUrlParams} /> + ); + wrapper + .find('[data-test-subj="xpack.uptime.monitorList.pageSizeSelect.popoverOpen"]') + .first() + .simulate('click'); + wrapper + .find('[data-test-subj="xpack.uptime.monitorList.pageSizeSelect.sizeSelectItem25"]') + .first() + .simulate('click'); + expect(setSize).toHaveBeenCalledTimes(1); + expect(setSize.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 25, + ], + ] + `); + expect(setUrlParams).toHaveBeenCalledTimes(1); + expect(setUrlParams.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "pagination": undefined, + }, + ], + ] + `); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_pagination.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_pagination.test.tsx index 1aef9281a3066..b08b8b3fabc3e 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_pagination.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_pagination.test.tsx @@ -99,6 +99,8 @@ describe('MonitorListPagination component', () => { dangerColor="danger" data={{ monitorStates: result }} loading={false} + pageSize={25} + setPageSize={jest.fn()} successColor="primary" hasActiveFilters={false} /> @@ -114,6 +116,8 @@ describe('MonitorListPagination component', () => { data={{}} loading={false} successColor="primary" + pageSize={25} + setPageSize={jest.fn()} hasActiveFilters={false} /> ); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list.tsx index 58250222e1330..a9fb1ce2f4be1 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list.tsx @@ -33,6 +33,7 @@ import { MonitorPageLink } from './monitor_page_link'; import { OverviewPageLink } from './overview_page_link'; import * as labels from './translations'; import { MonitorListDrawer } from '../../connected'; +import { MonitorListPageSizeSelect } from './monitor_list_page_size_select'; interface MonitorListQueryResult { monitorStates?: MonitorSummaryResult; @@ -43,6 +44,8 @@ interface MonitorListProps { hasActiveFilters: boolean; successColor: string; linkParameters?: string; + pageSize: number; + setPageSize: (size: number) => void; } type Props = UptimeGraphQLQueryProps<MonitorListQueryResult> & MonitorListProps; @@ -185,20 +188,27 @@ export const MonitorListComponent = (props: Props) => { columns={columns} /> <EuiSpacer size="m" /> - <EuiFlexGroup responsive={false}> + <EuiFlexGroup justifyContent="spaceBetween" responsive={false}> <EuiFlexItem grow={false}> - <OverviewPageLink - dataTestSubj="xpack.uptime.monitorList.prevButton" - direction="prev" - pagination={prevPagePagination} - /> + <MonitorListPageSizeSelect size={props.pageSize} setSize={props.setPageSize} /> </EuiFlexItem> <EuiFlexItem grow={false}> - <OverviewPageLink - dataTestSubj="xpack.uptime.monitorList.nextButton" - direction="next" - pagination={nextPagePagination} - /> + <EuiFlexGroup responsive={false}> + <EuiFlexItem grow={false}> + <OverviewPageLink + dataTestSubj="xpack.uptime.monitorList.prevButton" + direction="prev" + pagination={prevPagePagination} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <OverviewPageLink + dataTestSubj="xpack.uptime.monitorList.nextButton" + direction="next" + pagination={nextPagePagination} + /> + </EuiFlexItem> + </EuiFlexGroup> </EuiFlexItem> </EuiFlexGroup> </EuiPanel> diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_page_size_select.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_page_size_select.tsx new file mode 100644 index 0000000000000..abfc1384bb1af --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_page_size_select.tsx @@ -0,0 +1,132 @@ +/* + * 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 { EuiButtonEmpty, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover } from '@elastic/eui'; +import React, { useState, useEffect } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useUrlParams, UpdateUrlParams } from '../../../hooks'; + +interface PopoverButtonProps { + setIsOpen: (isOpen: boolean) => any; + size: number; +} + +const PopoverButton: React.FC<PopoverButtonProps> = ({ setIsOpen, size }) => ( + <EuiButtonEmpty + color="text" + data-test-subj="xpack.uptime.monitorList.pageSizeSelect.popoverOpen" + iconType="arrowDown" + iconSide="right" + onClick={() => setIsOpen(true)} + > + <FormattedMessage + id="xpack.uptime.monitorList.pageSizePopoverButtonText" + defaultMessage="Rows per page: {size}" + values={{ size }} + /> + </EuiButtonEmpty> +); + +interface ContextItemProps { + 'data-test-subj': string; + key: string; + numRows: number; +} + +const items: ContextItemProps[] = [ + { + 'data-test-subj': 'xpack.uptime.monitorList.pageSizeSelect.sizeSelectItem10', + key: '10 rows', + numRows: 10, + }, + { + 'data-test-subj': 'xpack.uptime.monitorList.pageSizeSelect.sizeSelectItem25', + key: '25 rows', + numRows: 25, + }, + { + 'data-test-subj': 'xpack.uptime.monitorList.pageSizeSelect.sizeSelectItem50', + key: '50 rows', + numRows: 50, + }, + { + 'data-test-subj': 'xpack.uptime.monitorList.pageSizeSelect.sizeSelectItem100', + key: '100 rows', + numRows: 100, + }, +]; + +const LOCAL_STORAGE_KEY = 'xpack.uptime.monitorList.pageSize'; + +interface MonitorListPageSizeSelectProps { + size: number; + setSize: (value: number) => void; +} + +/** + * This component wraps the underlying UI functionality to make the component more testable. + * The features leveraged in this function are tested elsewhere, and are not novel to this component. + */ +export const MonitorListPageSizeSelect: React.FC<MonitorListPageSizeSelectProps> = ({ + size, + setSize, +}) => { + const [, setUrlParams] = useUrlParams(); + + useEffect(() => { + localStorage.setItem(LOCAL_STORAGE_KEY, size.toString()); + }, [size]); + + return ( + <MonitorListPageSizeSelectComponent size={size} setSize={setSize} setUrlParams={setUrlParams} /> + ); +}; + +interface ComponentProps extends MonitorListPageSizeSelectProps { + setUrlParams: UpdateUrlParams; +} + +/** + * This function contains the UI functionality for the page select feature. It's agnostic to any + * external services/features, and focuses only on providing the UI and handling user interaction. + */ +export const MonitorListPageSizeSelectComponent: React.FC<ComponentProps> = ({ + size, + setSize, + setUrlParams, +}) => { + const [isOpen, setIsOpen] = useState(false); + return ( + <EuiPopover + button={<PopoverButton setIsOpen={value => setIsOpen(value)} size={size} />} + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + anchorPosition="upLeft" + > + <EuiContextMenuPanel + items={items.map(({ 'data-test-subj': dataTestSubj, key, numRows }) => ( + <EuiContextMenuItem + data-test-subj={dataTestSubj} + key={key} + icon={size === numRows ? 'check' : 'empty'} + onClick={() => { + setSize(numRows); + // reset pagination because the page size has changed + setUrlParams({ pagination: undefined }); + setIsOpen(false); + }} + > + <FormattedMessage + id="xpack.uptime.monitorList.pageSizeSelect.numRowsItemMessage" + defaultMessage="{numRows} rows" + values={{ numRows }} + /> + </EuiContextMenuItem> + ))} + /> + </EuiPopover> + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/overview_page_link.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/overview_page_link.tsx index 9d8f28cdb34c3..f79da4c98dfed 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/overview_page_link.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/overview_page_link.tsx @@ -7,8 +7,13 @@ import { EuiButtonIcon } from '@elastic/eui'; import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; import { useUrlParams } from '../../../hooks'; +const OverviewPageLinkButtonIcon = styled(EuiButtonIcon)` + padding-top: 12px; +`; + interface OverviewPageLinkProps { dataTestSubj: string; direction: string; @@ -38,8 +43,8 @@ export const OverviewPageLink: FunctionComponent<OverviewPageLinkProps> = ({ }); return ( - <EuiButtonIcon - color={'text'} + <OverviewPageLinkButtonIcon + color="text" onClick={() => { updateUrlParams({ pagination }); }} diff --git a/x-pack/legacy/plugins/uptime/public/hooks/index.ts b/x-pack/legacy/plugins/uptime/public/hooks/index.ts index cfb8d71f783a6..e022248df407a 100644 --- a/x-pack/legacy/plugins/uptime/public/hooks/index.ts +++ b/x-pack/legacy/plugins/uptime/public/hooks/index.ts @@ -4,6 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export { useUrlParams } from './use_url_params'; +export * from './use_url_params'; export * from './use_telemetry'; export * from './update_kuery_string'; diff --git a/x-pack/legacy/plugins/uptime/public/hooks/use_url_params.ts b/x-pack/legacy/plugins/uptime/public/hooks/use_url_params.ts index dc309943d7cf9..20063b2c1bc93 100644 --- a/x-pack/legacy/plugins/uptime/public/hooks/use_url_params.ts +++ b/x-pack/legacy/plugins/uptime/public/hooks/use_url_params.ts @@ -8,8 +8,10 @@ import { parse, stringify } from 'query-string'; import { useLocation, useHistory } from 'react-router-dom'; import { UptimeUrlParams, getSupportedUrlParams } from '../lib/helper'; -type GetUrlParams = () => UptimeUrlParams; -type UpdateUrlParams = (updatedParams: { [key: string]: string | number | boolean }) => void; +export type GetUrlParams = () => UptimeUrlParams; +export type UpdateUrlParams = (updatedParams: { + [key: string]: string | number | boolean | undefined; +}) => void; export type UptimeUrlParamsHook = () => [GetUrlParams, UpdateUrlParams]; diff --git a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx index a8a35fd2681b6..943dbd6bd57ba 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx @@ -5,7 +5,7 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import React, { useContext, useEffect } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { @@ -40,9 +40,26 @@ const EuiFlexItemStyled = styled(EuiFlexItem)` } `; +// TODO: these values belong deeper down in the monitor +// list pagination control, but are here temporarily until we +// are done removing GraphQL +const DEFAULT_PAGE_SIZE = 10; +const LOCAL_STORAGE_KEY = 'xpack.uptime.monitorList.pageSize'; +const getMonitorListPageSizeValue = () => { + const value = parseInt(localStorage.getItem(LOCAL_STORAGE_KEY) ?? '', 10); + if (isNaN(value)) { + return DEFAULT_PAGE_SIZE; + } + return value; +}; + export const OverviewPageComponent = ({ autocomplete, indexPattern, setEsKueryFilters }: Props) => { const { colors } = useContext(UptimeThemeContext); const [getUrlParams] = useUrlParams(); + // TODO: this is temporary until we migrate the monitor list to our Redux implementation + const [monitorListPageSize, setMonitorListPageSize] = useState<number>( + getMonitorListPageSizeValue() + ); const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = getUrlParams(); const { dateRangeStart, @@ -106,10 +123,13 @@ export const OverviewPageComponent = ({ autocomplete, indexPattern, setEsKueryFi hasActiveFilters={!!esFilters} implementsCustomErrorState={true} linkParameters={linkParameters} + pageSize={monitorListPageSize} + setPageSize={setMonitorListPageSize} successColor={colors.success} variables={{ ...sharedProps, pagination, + pageSize: monitorListPageSize, }} /> </EmptyState> diff --git a/x-pack/legacy/plugins/uptime/public/queries/monitor_states_query.ts b/x-pack/legacy/plugins/uptime/public/queries/monitor_states_query.ts index 9e609786094d5..676e638c239de 100644 --- a/x-pack/legacy/plugins/uptime/public/queries/monitor_states_query.ts +++ b/x-pack/legacy/plugins/uptime/public/queries/monitor_states_query.ts @@ -7,13 +7,14 @@ import gql from 'graphql-tag'; export const monitorStatesQueryString = ` -query MonitorStates($dateRangeStart: String!, $dateRangeEnd: String!, $pagination: String, $filters: String, $statusFilter: String) { +query MonitorStates($dateRangeStart: String!, $dateRangeEnd: String!, $pagination: String, $filters: String, $statusFilter: String, $pageSize: Int) { monitorStates: getMonitorStates( dateRangeStart: $dateRangeStart dateRangeEnd: $dateRangeEnd pagination: $pagination filters: $filters statusFilter: $statusFilter + pageSize: $pageSize ) { prevPagePagination nextPagePagination diff --git a/x-pack/plugins/uptime/server/graphql/monitor_states/resolvers.ts b/x-pack/plugins/uptime/server/graphql/monitor_states/resolvers.ts index 08973b217b96c..479c06234ca66 100644 --- a/x-pack/plugins/uptime/server/graphql/monitor_states/resolvers.ts +++ b/x-pack/plugins/uptime/server/graphql/monitor_states/resolvers.ts @@ -32,7 +32,7 @@ export const createMonitorStatesResolvers: CreateUMGraphQLResolvers = ( Query: { async getMonitorStates( _resolver, - { dateRangeStart, dateRangeEnd, filters, pagination, statusFilter }, + { dateRangeStart, dateRangeEnd, filters, pagination, statusFilter, pageSize }, { APICaller, savedObjectsClient } ): Promise<MonitorSummaryResult> { const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings( @@ -53,6 +53,7 @@ export const createMonitorStatesResolvers: CreateUMGraphQLResolvers = ( dateRangeStart, dateRangeEnd, pagination: decodedPagination, + pageSize, filters, // this is added to make typescript happy, // this sort of reassignment used to be further downstream but I've moved it here diff --git a/x-pack/plugins/uptime/server/graphql/monitor_states/schema.gql.ts b/x-pack/plugins/uptime/server/graphql/monitor_states/schema.gql.ts index d088aed951204..6ab564fdeb532 100644 --- a/x-pack/plugins/uptime/server/graphql/monitor_states/schema.gql.ts +++ b/x-pack/plugins/uptime/server/graphql/monitor_states/schema.gql.ts @@ -177,6 +177,7 @@ export const monitorStatesSchema = gql` pagination: String filters: String statusFilter: String + pageSize: Int ): MonitorSummaryResult } `; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_states.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_states.ts index bfccb34ab94de..d7842d1a0b4aa 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_monitor_states.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_states.ts @@ -24,6 +24,7 @@ export interface GetMonitorStatesParams { dateRangeStart: string; dateRangeEnd: string; pagination?: CursorPagination; + pageSize: number; filters?: string | null; statusFilter?: string; } @@ -53,12 +54,12 @@ export const getMonitorStates: UMElasticsearchQueryFn< dateRangeStart, dateRangeEnd, pagination, + pageSize, filters, statusFilter, }) => { pagination = pagination || CONTEXT_DEFAULTS.CURSOR_PAGINATION; statusFilter = statusFilter === null ? undefined : statusFilter; - const size = 10; const queryContext = new QueryContext( callES, @@ -67,7 +68,7 @@ export const getMonitorStates: UMElasticsearchQueryFn< dateRangeEnd, pagination, filters && filters !== '' ? JSON.parse(filters) : null, - size, + pageSize, statusFilter ); diff --git a/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts b/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts index 511cdb6d004fa..a293426195d23 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts +++ b/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts @@ -22,6 +22,7 @@ export default function({ getService }: FtrProviderContext) { variables: { dateRangeStart, dateRangeEnd, + pageSize: 10, ...variables, }, }; diff --git a/x-pack/test/api_integration/apis/uptime/rest/index.ts b/x-pack/test/api_integration/apis/uptime/rest/index.ts index 712a8bc40c41c..b2236e1bb6308 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/index.ts +++ b/x-pack/test/api_integration/apis/uptime/rest/index.ts @@ -17,15 +17,20 @@ export default function({ getService, loadTestFile }: FtrProviderContext) { describe('uptime REST endpoints', () => { beforeEach('clear settings', async () => { try { - server.savedObjects.delete({ + await server.savedObjects.delete({ type: settingsObjectType, id: settingsObjectId, }); } catch (e) { // a 404 just means the doc is already missing - if (e.statuscode !== 404) { + if (e.response.status !== 404) { + const { status, statusText, data, headers, config } = e.response; throw new Error( - `error attempting to delete settings (${e.statuscode}): ${JSON.stringify(e)}` + `error attempting to delete settings:\n${JSON.stringify( + { status, statusText, data, headers, config }, + null, + 2 + )}` ); } } diff --git a/x-pack/test/functional/apps/uptime/overview.ts b/x-pack/test/functional/apps/uptime/overview.ts index 9a879032fadc1..f3b587b9bc1e2 100644 --- a/x-pack/test/functional/apps/uptime/overview.ts +++ b/x-pack/test/functional/apps/uptime/overview.ts @@ -65,10 +65,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '0018-up', '0019-up', ]); - await retry.tryForTime(12000, async () => { - // there should now be pagination data in the URL - await pageObjects.uptime.pageUrlContains('pagination'); - }); + // there should now be pagination data in the URL + await pageObjects.uptime.pageUrlContains('pagination'); await pageObjects.uptime.setStatusFilter('up'); await pageObjects.uptime.pageHasExpectedIds([ '0000-intermittent', @@ -82,10 +80,86 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '0008-up', '0009-up', ]); - await retry.tryForTime(12000, async () => { - // ensure that pagination is removed from the URL - await pageObjects.uptime.pageUrlContains('pagination', false); - }); + // ensure that pagination is removed from the URL + await pageObjects.uptime.pageUrlContains('pagination', false); + }); + + it('clears pagination parameters when size changes', async () => { + await pageObjects.uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); + await pageObjects.uptime.changePage('next'); + await pageObjects.uptime.pageUrlContains('pagination'); + await pageObjects.uptime.setMonitorListPageSize(50); + // the pagination parameter should be cleared after a size change + await pageObjects.uptime.pageUrlContains('pagination', false); + }); + + it('pagination size updates to reflect current selection', async () => { + await pageObjects.uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); + await pageObjects.uptime.pageHasExpectedIds([ + '0000-intermittent', + '0001-up', + '0002-up', + '0003-up', + '0004-up', + '0005-up', + '0006-up', + '0007-up', + '0008-up', + '0009-up', + ]); + await pageObjects.uptime.setMonitorListPageSize(50); + await pageObjects.uptime.pageHasExpectedIds([ + '0000-intermittent', + '0001-up', + '0002-up', + '0003-up', + '0004-up', + '0005-up', + '0006-up', + '0007-up', + '0008-up', + '0009-up', + '0010-down', + '0011-up', + '0012-up', + '0013-up', + '0014-up', + '0015-intermittent', + '0016-up', + '0017-up', + '0018-up', + '0019-up', + '0020-down', + '0021-up', + '0022-up', + '0023-up', + '0024-up', + '0025-up', + '0026-up', + '0027-up', + '0028-up', + '0029-up', + '0030-intermittent', + '0031-up', + '0032-up', + '0033-up', + '0034-up', + '0035-up', + '0036-up', + '0037-up', + '0038-up', + '0039-up', + '0040-down', + '0041-up', + '0042-up', + '0043-up', + '0044-up', + '0045-intermittent', + '0046-up', + '0047-up', + '0048-up', + '0049-up', + ]); }); describe('snapshot counts', () => { diff --git a/x-pack/test/functional/page_objects/uptime_page.ts b/x-pack/test/functional/page_objects/uptime_page.ts index e18c7d4154728..0b8e994ba8095 100644 --- a/x-pack/test/functional/page_objects/uptime_page.ts +++ b/x-pack/test/functional/page_objects/uptime_page.ts @@ -66,12 +66,14 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo return await uptimeService.pageHasDataMissing(); } - public async pageHasExpectedIds(monitorIdsToCheck: string[]) { - await Promise.all(monitorIdsToCheck.map(id => uptimeService.monitorPageLinkExists(id))); + public async pageHasExpectedIds(monitorIdsToCheck: string[]): Promise<void> { + return retry.tryForTime(15000, async () => { + await Promise.all(monitorIdsToCheck.map(id => uptimeService.monitorPageLinkExists(id))); + }); } - public async pageUrlContains(value: string, expected: boolean = true) { - await retry.try(async () => { + public async pageUrlContains(value: string, expected: boolean = true): Promise<void> { + return retry.tryForTime(12000, async () => { expect(await uptimeService.urlContains(value)).to.eql(expected); }); } @@ -144,5 +146,10 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo await alerts.setLocationsSelectable(); await alerts.clickSaveAlertButtion(); } + + public async setMonitorListPageSize(size: number): Promise<void> { + await uptimeService.openPageSizeSelectPopover(); + return uptimeService.clickPageSizeSelectPopoverItem(size); + } })(); } diff --git a/x-pack/test/functional/services/uptime.ts b/x-pack/test/functional/services/uptime.ts index 57beedc5e0f29..f9902d0142b96 100644 --- a/x-pack/test/functional/services/uptime.ts +++ b/x-pack/test/functional/services/uptime.ts @@ -194,5 +194,14 @@ export function UptimeProvider({ getService }: FtrProviderContext) { timeout: 3000, }); }, + async openPageSizeSelectPopover(): Promise<void> { + return testSubjects.click('xpack.uptime.monitorList.pageSizeSelect.popoverOpen', 5000); + }, + async clickPageSizeSelectPopoverItem(size: number = 10): Promise<void> { + return testSubjects.click( + `xpack.uptime.monitorList.pageSizeSelect.sizeSelectItem${size.toString()}`, + 5000 + ); + }, }; } From afca33b5206e1389af18da81a91682904f2b4c79 Mon Sep 17 00:00:00 2001 From: Lee Drengenberg <lee.drengenberg@elastic.co> Date: Mon, 23 Mar 2020 19:18:35 +0000 Subject: [PATCH 034/179] add relationship test on Saved Objects (#59968) * just a demo of function to return saved object table elements * fix esArchive data, extend import objects test case for relationships * improved data-test-subjs * update snapshot for jest test * unskip other half of the tests * removed commented-out code * use new findByTestSubject methods Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../__snapshots__/relationships.test.js.snap | 12 ++ .../components/relationships/relationships.js | 16 ++- .../objects_table/components/table/table.js | 1 + .../apps/management/_import_objects.js | 109 ++++++++---------- .../es_archiver/management/data.json.gz | Bin 1295 -> 1297 bytes test/functional/page_objects/settings_page.ts | 65 +++++++++-- 6 files changed, 125 insertions(+), 78 deletions(-) diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap index c1241d5d7c1e5..728944f3ccbfe 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap @@ -53,6 +53,7 @@ exports[`Relationships should render dashboards normally 1`] = ` "width": "50px", }, Object { + "data-test-subj": "directRelationship", "dataType": "string", "field": "relationship", "name": "Direct relationship", @@ -72,6 +73,7 @@ exports[`Relationships should render dashboards normally 1`] = ` "actions": Array [ Object { "available": [Function], + "data-test-subj": "relationshipsTableAction-inspect", "description": "Inspect this saved object", "icon": "inspect", "name": "Inspect", @@ -117,6 +119,7 @@ exports[`Relationships should render dashboards normally 1`] = ` } pagination={true} responsive={true} + rowProps={[Function]} search={ Object { "filters": Array [ @@ -263,6 +266,7 @@ exports[`Relationships should render index patterns normally 1`] = ` "width": "50px", }, Object { + "data-test-subj": "directRelationship", "dataType": "string", "field": "relationship", "name": "Direct relationship", @@ -282,6 +286,7 @@ exports[`Relationships should render index patterns normally 1`] = ` "actions": Array [ Object { "available": [Function], + "data-test-subj": "relationshipsTableAction-inspect", "description": "Inspect this saved object", "icon": "inspect", "name": "Inspect", @@ -327,6 +332,7 @@ exports[`Relationships should render index patterns normally 1`] = ` } pagination={true} responsive={true} + rowProps={[Function]} search={ Object { "filters": Array [ @@ -429,6 +435,7 @@ exports[`Relationships should render searches normally 1`] = ` "width": "50px", }, Object { + "data-test-subj": "directRelationship", "dataType": "string", "field": "relationship", "name": "Direct relationship", @@ -448,6 +455,7 @@ exports[`Relationships should render searches normally 1`] = ` "actions": Array [ Object { "available": [Function], + "data-test-subj": "relationshipsTableAction-inspect", "description": "Inspect this saved object", "icon": "inspect", "name": "Inspect", @@ -493,6 +501,7 @@ exports[`Relationships should render searches normally 1`] = ` } pagination={true} responsive={true} + rowProps={[Function]} search={ Object { "filters": Array [ @@ -595,6 +604,7 @@ exports[`Relationships should render visualizations normally 1`] = ` "width": "50px", }, Object { + "data-test-subj": "directRelationship", "dataType": "string", "field": "relationship", "name": "Direct relationship", @@ -614,6 +624,7 @@ exports[`Relationships should render visualizations normally 1`] = ` "actions": Array [ Object { "available": [Function], + "data-test-subj": "relationshipsTableAction-inspect", "description": "Inspect this saved object", "icon": "inspect", "name": "Inspect", @@ -659,6 +670,7 @@ exports[`Relationships should render visualizations normally 1`] = ` } pagination={true} responsive={true} + rowProps={[Function]} search={ Object { "filters": Array [ diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js index ee9fb70e31fb2..ce3415ad2f0e7 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js @@ -135,6 +135,7 @@ export class Relationships extends Component { aria-label={getSavedObjectLabel(type)} type={object.meta.icon || 'apps'} size="s" + data-test-subj="relationshipsObjectType" /> </EuiToolTip> ); @@ -149,6 +150,7 @@ export class Relationships extends Component { dataType: 'string', sortable: false, width: '125px', + 'data-test-subj': 'directRelationship', render: relationship => { if (relationship === 'parent') { return ( @@ -187,10 +189,16 @@ export class Relationships extends Component { const { path } = object.meta.inAppUrl || {}; const canGoInApp = this.props.canGoInApp(object); if (!canGoInApp) { - return <EuiText size="s">{title || getDefaultTitle(object)}</EuiText>; + return ( + <EuiText size="s" data-test-subj="relationshipsTitle"> + {title || getDefaultTitle(object)} + </EuiText> + ); } return ( - <EuiLink href={chrome.addBasePath(path)}>{title || getDefaultTitle(object)}</EuiLink> + <EuiLink href={chrome.addBasePath(path)} data-test-subj="relationshipsTitle"> + {title || getDefaultTitle(object)} + </EuiLink> ); }, }, @@ -211,6 +219,7 @@ export class Relationships extends Component { ), type: 'icon', icon: 'inspect', + 'data-test-subj': 'relationshipsTableAction-inspect', onClick: object => goInspectObject(object), available: object => !!object.meta.editUrl, }, @@ -295,6 +304,9 @@ export class Relationships extends Component { columns={columns} pagination={true} search={search} + rowProps={() => ({ + 'data-test-subj': `relationshipsTableRow`, + })} /> </div> ); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js index 386b35399b754..5342693113bca 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js @@ -186,6 +186,7 @@ export class Table extends PureComponent { aria-label={getSavedObjectLabel(type)} type={object.meta.icon || 'apps'} size="s" + data-test-subj="objectType" /> </EuiToolTip> ); diff --git a/test/functional/apps/management/_import_objects.js b/test/functional/apps/management/_import_objects.js index 8aabe42ccc3db..cd39f1cf25ccc 100644 --- a/test/functional/apps/management/_import_objects.js +++ b/test/functional/apps/management/_import_objects.js @@ -19,12 +19,14 @@ import expect from '@kbn/expect'; import path from 'path'; +import { indexBy } from 'lodash'; export default function({ getService, getPageObjects }) { const kibanaServer = getService('kibanaServer'); const esArchiver = getService('esArchiver'); const PageObjects = getPageObjects(['common', 'settings', 'header']); const testSubjects = getService('testSubjects'); + const log = getService('log'); describe('import objects', function describeIndexTests() { describe('.ndjson file', () => { @@ -33,6 +35,7 @@ export default function({ getService, getPageObjects }) { await kibanaServer.uiSettings.replace({}); await PageObjects.settings.navigateTo(); await esArchiver.load('management'); + await PageObjects.settings.clickKibanaSavedObjects(); }); afterEach(async function() { @@ -40,20 +43,31 @@ export default function({ getService, getPageObjects }) { }); it('should import saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects.ndjson') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); - const objects = await PageObjects.settings.getSavedObjectsInTable(); - const isSavedObjectImported = objects.includes('Log Agents'); - expect(isSavedObjectImported).to.be(true); + + // get all the elements in the table, and index them by the 'title' visible text field + const elements = indexBy( + await PageObjects.settings.getSavedObjectElementsInTable(), + 'title' + ); + log.debug("check that 'Log Agents' is in table as a visualization"); + expect(elements['Log Agents'].objectType).to.eql('visualization'); + + await elements['logstash-*'].relationshipsElement.click(); + const flyout = indexBy(await PageObjects.settings.getRelationshipFlyout(), 'title'); + log.debug( + "check that 'Shared-Item Visualization AreaChart' shows 'logstash-*' as it's Parent" + ); + expect(flyout['Shared-Item Visualization AreaChart'].relationship).to.eql('Parent'); + log.debug("check that 'Log Agents' shows 'logstash-*' as it's Parent"); + expect(flyout['Log Agents'].relationship).to.eql('Parent'); }); it('should provide dialog to allow the importing of saved objects with index pattern conflicts', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_conflicts.ndjson') ); @@ -65,15 +79,12 @@ export default function({ getService, getPageObjects }) { await PageObjects.settings.clickConfirmChanges(); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object with index pattern conflict'); expect(isSavedObjectImported).to.be(true); }); it('should allow the user to override duplicate saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); - // This data has already been loaded by the "visualize" esArchive. We'll load it again // so that we can override the existing visualization. await PageObjects.settings.importFile( @@ -93,8 +104,6 @@ export default function({ getService, getPageObjects }) { }); it('should allow the user to cancel overriding duplicate saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); - // This data has already been loaded by the "visualize" esArchive. We'll load it again // so that we can be prompted to override the existing visualization. await PageObjects.settings.importFile( @@ -114,21 +123,17 @@ export default function({ getService, getPageObjects }) { }); it('should import saved objects linked to saved searches', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_saved_search.ndjson') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.ndjson') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -136,14 +141,11 @@ export default function({ getService, getPageObjects }) { }); it('should not import saved objects linked to saved searches when saved search does not exist', async function() { - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.ndjson') ); await PageObjects.settings.checkNoneImported(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -151,12 +153,13 @@ export default function({ getService, getPageObjects }) { }); it('should not import saved objects linked to saved searches when saved search index pattern does not exist', async function() { - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaIndexPatterns(); - await PageObjects.settings.removeLogstashIndexPatternIfExist(); + const elements = indexBy( + await PageObjects.settings.getSavedObjectElementsInTable(), + 'title' + ); + await elements['logstash-*'].checkbox.click(); + await PageObjects.settings.clickSavedObjectsDelete(); - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_with_saved_search.ndjson') ); @@ -164,7 +167,6 @@ export default function({ getService, getPageObjects }) { await PageObjects.settings.checkImportConflictsWarning(); await PageObjects.settings.clickConfirmChanges(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -173,14 +175,11 @@ export default function({ getService, getPageObjects }) { it('should import saved objects with index patterns when index patterns already exists', async () => { // First, import the objects - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_with_index_patterns.ndjson') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - // Wait for all the saves to happen - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object imported with index pattern'); @@ -189,19 +188,19 @@ export default function({ getService, getPageObjects }) { it('should import saved objects with index patterns when index patterns does not exists', async () => { // First, we need to delete the index pattern - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaIndexPatterns(); - await PageObjects.settings.removeLogstashIndexPatternIfExist(); + const elements = indexBy( + await PageObjects.settings.getSavedObjectElementsInTable(), + 'title' + ); + await elements['logstash-*'].checkbox.click(); + await PageObjects.settings.clickSavedObjectsDelete(); // Then, import the objects - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_with_index_patterns.ndjson') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - // Wait for all the saves to happen - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object imported with index pattern'); @@ -215,6 +214,7 @@ export default function({ getService, getPageObjects }) { await kibanaServer.uiSettings.replace({}); await PageObjects.settings.navigateTo(); await esArchiver.load('management'); + await PageObjects.settings.clickKibanaSavedObjects(); }); afterEach(async function() { @@ -222,20 +222,17 @@ export default function({ getService, getPageObjects }) { }); it('should import saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects.json') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('Log Agents'); expect(isSavedObjectImported).to.be(true); }); it('should provide dialog to allow the importing of saved objects with index pattern conflicts', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects-conflicts.json') ); @@ -248,15 +245,12 @@ export default function({ getService, getPageObjects }) { await PageObjects.settings.clickConfirmChanges(); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object with index pattern conflict'); expect(isSavedObjectImported).to.be(true); }); it('should allow the user to override duplicate saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); - // This data has already been loaded by the "visualize" esArchive. We'll load it again // so that we can override the existing visualization. await PageObjects.settings.importFile( @@ -277,8 +271,6 @@ export default function({ getService, getPageObjects }) { }); it('should allow the user to cancel overriding duplicate saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); - // This data has already been loaded by the "visualize" esArchive. We'll load it again // so that we can be prompted to override the existing visualization. await PageObjects.settings.importFile( @@ -299,21 +291,17 @@ export default function({ getService, getPageObjects }) { }); it('should import saved objects linked to saved searches', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_saved_search.json') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.json') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -321,14 +309,11 @@ export default function({ getService, getPageObjects }) { }); it('should not import saved objects linked to saved searches when saved search does not exist', async function() { - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.json') ); await PageObjects.settings.checkImportFailedWarning(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -337,7 +322,6 @@ export default function({ getService, getPageObjects }) { it('should not import saved objects linked to saved searches when saved search index pattern does not exist', async function() { // First, import the saved search - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_saved_search.json') ); @@ -346,21 +330,21 @@ export default function({ getService, getPageObjects }) { await PageObjects.settings.clickImportDone(); // Second, we need to delete the index pattern - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaIndexPatterns(); - await PageObjects.settings.removeLogstashIndexPatternIfExist(); + const elements = indexBy( + await PageObjects.settings.getSavedObjectElementsInTable(), + 'title' + ); + await elements['logstash-*'].checkbox.click(); + await PageObjects.settings.clickSavedObjectsDelete(); // Last, import a saved object connected to the saved search // This should NOT show the conflicts - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.json') ); // Wait for all the saves to happen await PageObjects.settings.checkNoneImported(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -369,14 +353,11 @@ export default function({ getService, getPageObjects }) { it('should import saved objects with index patterns when index patterns already exists', async () => { // First, import the objects - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_with_index_patterns.json') ); await PageObjects.settings.checkImportFailedWarning(); await PageObjects.settings.clickImportDone(); - // Wait for all the saves to happen - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object imported with index pattern'); @@ -385,19 +366,19 @@ export default function({ getService, getPageObjects }) { it('should import saved objects with index patterns when index patterns does not exists', async () => { // First, we need to delete the index pattern - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaIndexPatterns(); - await PageObjects.settings.removeLogstashIndexPatternIfExist(); + const elements = indexBy( + await PageObjects.settings.getSavedObjectElementsInTable(), + 'title' + ); + await elements['logstash-*'].checkbox.click(); + await PageObjects.settings.clickSavedObjectsDelete(); // Then, import the objects - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_with_index_patterns.json') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - // Wait for all the saves to happen - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object imported with index pattern'); diff --git a/test/functional/fixtures/es_archiver/management/data.json.gz b/test/functional/fixtures/es_archiver/management/data.json.gz index e4b8f7e954477592d8715764032c849e6751f9b0..cfb08a5ee3a609048ffa9743b5adc8db2b5cd7a3 100644 GIT binary patch literal 1297 zcmV+s1@8JEiwFP!000023e8(lZ`(Kwe($dc@-kp(f~LLOVR`B>+yOgm-9rxphNdtS zi?MZ;El-l0bV2_6QL^mXX@G07pwvTvn6$-Dq(n*-wLKY)#`d9t@q9GqDjlCX!ab9< zqipd39|d<@QF7Q!DrPO{a=x0uZ|VEl*@T_LyUA<@e@vFki?hl3#l;fdFX0`V{q7qZ zg)dzb(>t+bC2Q$M)jEBYGuo5UV<2lKKyu?+x!EqpB`aVto-f84R-i#I#-|J44^K2! zi!$@HRMn+L;u-!osTP$5j*lxQ4Is%2^c1UWM?iS;;pgBTR7>JDV!~^?(>?;0fs=52 z`GGPJ@4p+$*B}`-`cah@flO647X@OPuLZYxRJa-(WPmotOf_2*4wW1jM`_eBo?3=O zBT1-|&_D%PHmwclsbt1B<UW|PE|y`czq*o`wjz}<w=5&JUIuE4_OkH|AiEaMVK;s! z9*@(S%s}b}HnG`in<iH54{45oX^oaL&8g>nnls7>o=Sll5!Dbvc&2d2O#sh9WI5%^ zB3Fh|e8JLOlz{WLAdCZ2ly`<u3xX4N{u09-^=hTcs{#t8AL3aI^nE9>c4G5d@rD0F zae<phP8)rF6Y(gg)-tNCp%E4m$0y$!{XmwDYlGI?dk`AU=Q)TKV=9({iD%@tfxr#l zNOB8=%1N4p-Vi~b_-qI*<^R<1{B&3xeuZ)!>6#$c0&LJGhC0bayp~Lkv=lhr9O-+p z_<U;|xyh$)g?0($vVf^l8ht+E+VlFzOe!V9vBaydS5Py=j>cz|0;>2%v=vrDbY}o@ zpfZ&z4K<9*0*s+wG_(mYWui^0$L5iVEw(X~rt+!EIQ<H7q%=AuJ~E>po896&8m~s} z<y~@{_;@A0*@`$KIN-(!OP*$m&NlsHLF&`&nDWO5c8by2u1j!7x*Z{e)vmVV(@YGR z!%KH;Wh^Tyva_q@!;=idE1$kLyTT|+(^f(ss!fZh<TK{t!Z^>>s(LAf$*GTs6TwNd z!pU*!bAJVaNtHfA(dREffK7$&#%+^Ejfj_`3FdD<o_mouZ@dqEb3662{r8eN%??C* z`in<W(~H|sRqu=C-8lZs70h2@zg*4yV-y*_hLPW~rOJ?L^2ytA;O^Sjy4wN6+I-L5 zow(ob<YfE&YXtYg)J%$>%=v@)b<Q;K$tMfN<XfLJ`JjP)M6P}LErJ%vKvdW7J@Vj^ zLC;mW2KF)dcN*!}>>l{n<-d^HFBlsJ%{WlNbedm#Xn$XSy>hK>yDAVg`g872_~G!Q z26tRF--f?QJ10AeZ||*tISiF#EhKLWI~zZ<L*7bA>xS<9=5{yku<i~Ghn`hN1FMV< zYNQs|7Tsjm&t0E+585>9ob{=~_-}$<!V(nwaRuw4$l?4$T+CA{rdeScEf@#)`-hD% zy-CCQKT1ho)!Czch78L7G%b{}`Dal+&Lls1ne0+(5NcBe1`S;)=>x)$RVd#2OP1cX zM-Fbrd36Uk`22<GJ8y$-BEyQ+(tiKbC1>ma10140gkEmFNm-V83EQSj5CP1F>Iy!} zcPWR0wQCtI(mgHiyc-OyoIj0$NiK|4SzC+QYtBGcv^QY-R`C~h4lQB5BGETA2Cc1X z+-Z&N7oYmP^XB?WA<D!#yw>5)PD3(_W5iw}k%u1aoTf37BMS#zL+kum-(md+f2)(3 H_A~$hxXyvs literal 1295 zcmV+q1@QVGiwFP!000026U|#uZ`(Eye$THk{8C_ujX2p_)K4jj4d~FO4;cm&aUjs* zS>i&8DoMpo4gdEYDN2zX15HpP$A<v%oy7ZiM}8!ar|rpTG`0^FjOU{<SLyiF3GSJ! zon(tY@TcHTD@qQVNyV%MUCviC_&t3;JDad`csrTR;N4`oyf~YjUtBEV{Sw}?*&n{K zQFzleF})K@R<c%ZRISrTJEJ`tJq4;(0wg!Cm7DEiT(SZd<N0E&VFeoWVtl&r{qRC# zwJ0+$OI2N36rSP#Ce@;{!13pbNdqYI2ED}U+6fSzeE2bV2Hlc)kC^b-?zE49W#B9v z-+qt`%=`C-_BBX`jeb<6MIcj^%|(Hj=4-)i9u=+z2N|G^F;mUfghM3<#?cxzjHi*| z&{z_xBn(gimQ8B|dMcT*jkph{tczus>aXr3rmaXN%q`1^qnCl2V!UiT1IVt0BkYEE z;$fUNWCl_<u!+M~+cdFae@Jr#OdGV6X-+-o)0|PR;Hebo5m5~xgx?f&+$`WZh%Bev zS>(!4nlD(IixP0&7KH176y=>E^n&1ooxjB8j(WFJ<y8TN(hu=$2KwAd?48(HE57Bw zP+Z{Qk<+fezKM98Q)?O3(a;DBiPMuGjlQGG#&tmJojnMH=JOoHjxiNW!NfCi+l9al z-$-%`gw9Etgx(NAU-)bYt>kBJcz!x84ql<$N4h46y#O1GiJ?w15$`3FBW(qaH%Iy` z79Y39k(+!<E3{iEmjz6f(#ZLUYmfC&nN&)IYl&Ciub^j$iN<G@0;>2%v=vrDbaw!8 zpfZ&z4K<9*0*s;GG_(mYWui^0$HvIS5!)EbQ2A74oV-H3QW~8SUzyR5&2IA@4XaVJ zyi0BqU$4YBTM<VD7u+~u$<s{H#ioBPNPU|fQ~vnCNin+EbqVfBk0XSz+TC`1o{1rI zc<HXKjAcbdc6YUWc#>gw<=fZhR2W5R+Dhm{wQ2E`e8yZ{80WoORWGG5IrSBBA~<PQ zI5|#z?yn#)snSO%`uqh5u&I!4+%aj?n0P6g5dQY%xi@+9#&hVK+o`wh-<QmJc3{%W zUp$hUUfhPNdS5K>#_?aSVEzjG<!a_1SCQdkxbi!-R2eEwKKX4Nw7d4P?(2YIZNAs; zPTb$_<YfE&&j{{?shJc%nB#-_b<Q;K$tMfN<lnw#@<9Xph+6yduLxQo15;hU_r!yb z40=@M8ra9+pJ}XLvwPrQm;XU(zu?+1XvTp8rqle|L;L&s>y>M5+f{+MTutY3AN21v zxJ#+|HvF5kGq02Qx0;8;fJoLt@}_*VVUr!|Rzg}gK<78NyK#qTcPQvR-;V~qA06y> zEv_w+Vb_~oUwIGOG)|lKrNa1kLhr*y6Z>%m>!HcvEJIw(ODd*WVHz(O2lv~DjWE5b z!TCQ*Nng9!lYNE^%Kl_5l(P9}Q9jNWKSi1B5@!%9Pz5dxx>C~9{!mpY-m)S~@7jw6 zH?O?A104MQh3PvVgKoOQiq+D7`_rXh>;MBCl0Jl9Zhc5umU#=?rWg<b%!cj?Oys+i zL&4e|ix%mg7k8fQLM!J_SGpt@#;UBXxa>7&AS>GMT>4S*pXD6dsCq{t12h4xt!hZK z#`cR(ecgHUkxC)T#L-$ytFzOPvf{WdFHy)t4|dMexL%_Q2VEoUSgTJ<{{ycKSPa}W F000#3i^u=~ diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index e0f64340ca7dc..25706fda74925 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -47,6 +47,7 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider async clickKibanaSavedObjects() { await testSubjects.click('objects'); + await this.waitUntilSavedObjectsTableIsNotLoading(); } async clickKibanaIndexPatterns() { @@ -648,6 +649,7 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider async clickImportDone() { await testSubjects.click('importSavedObjectsDoneBtn'); + await this.waitUntilSavedObjectsTableIsNotLoading(); } async clickConfirmChanges() { @@ -681,9 +683,38 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider }); } + async getSavedObjectElementsInTable() { + const rows = await testSubjects.findAll('~savedObjectsTableRow'); + return mapAsync(rows, async row => { + const checkbox = await row.findByCssSelector('[data-test-subj*="checkboxSelectRow"]'); + // return the object type aria-label="index patterns" + const objectType = await row.findByTestSubject('objectType'); + const titleElement = await row.findByTestSubject('savedObjectsTableRowTitle'); + // not all rows have inspect button - Advanced Settings objects don't + let inspectElement; + const innerHtml = await row.getAttribute('innerHTML'); + if (innerHtml.includes('Inspect')) { + inspectElement = await row.findByTestSubject('savedObjectsTableAction-inspect'); + } else { + inspectElement = null; + } + const relationshipsElement = await row.findByTestSubject( + 'savedObjectsTableAction-relationships' + ); + return { + checkbox, + objectType: await objectType.getAttribute('aria-label'), + titleElement, + title: await titleElement.getVisibleText(), + inspectElement, + relationshipsElement, + }; + }); + } + async getSavedObjectsInTable() { const table = await testSubjects.find('savedObjectsTable'); - const cells = await table.findAllByCssSelector('td:nth-child(3)'); + const cells = await table.findAllByTestSubject('savedObjectsTableRowTitle'); const objects = []; for (const cell of cells) { @@ -693,6 +724,23 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider return objects; } + async getRelationshipFlyout() { + const rows = await testSubjects.findAll('relationshipsTableRow'); + return mapAsync(rows, async row => { + const objectType = await row.findByTestSubject('relationshipsObjectType'); + const relationship = await row.findByTestSubject('directRelationship'); + const titleElement = await row.findByTestSubject('relationshipsTitle'); + const inspectElement = await row.findByTestSubject('relationshipsTableAction-inspect'); + return { + objectType: await objectType.getAttribute('aria-label'), + relationship: await relationship.getVisibleText(), + titleElement, + title: await titleElement.getVisibleText(), + inspectElement, + }; + }); + } + async getSavedObjectsTableSummary() { const table = await testSubjects.find('savedObjectsTable'); const rows = await table.findAllByCssSelector('tbody tr'); @@ -723,17 +771,10 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider return await deleteButton.isEnabled(); } - async canSavedObjectBeDeleted(id: string) { - const allCheckBoxes = await testSubjects.findAll('checkboxSelectRow*'); - for (const checkBox of allCheckBoxes) { - if (await checkBox.isSelected()) { - await checkBox.click(); - } - } - - const checkBox = await testSubjects.find(`checkboxSelectRow-${id}`); - await checkBox.click(); - return await this.canSavedObjectsBeDeleted(); + async clickSavedObjectsDelete() { + await testSubjects.click('savedObjectsManagementDelete'); + await testSubjects.click('confirmModalConfirmButton'); + await this.waitUntilSavedObjectsTableIsNotLoading(); } } From 73deba16cc2c57d75f44a1a35b183e5a79736d00 Mon Sep 17 00:00:00 2001 From: Jen Huang <its.jenetic@gmail.com> Date: Mon, 23 Mar 2020 12:22:26 -0700 Subject: [PATCH 035/179] Ensure that the default datasources use the default config's namespace (#60823) Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../common/services/package_to_config.test.ts | 25 +++++++++++++++++++ .../common/services/package_to_config.ts | 6 ++++- .../ingest_manager/server/services/setup.ts | 18 ++++--------- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts b/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts index 5025c9b5288b9..357f407811880 100644 --- a/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts +++ b/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts @@ -298,6 +298,31 @@ describe('Ingest Manager - packageToConfig', () => { }, }); }); + it('returns datasource with namespace and description', () => { + expect( + packageToConfigDatasource( + mockPackage, + '1', + '2', + 'ds-1', + 'mock-namespace', + 'Test description' + ) + ).toEqual({ + config_id: '1', + enabled: true, + inputs: [], + name: 'ds-1', + namespace: 'mock-namespace', + description: 'Test description', + output_id: '2', + package: { + name: 'mock-package', + title: 'Mock package', + version: '0.0.0', + }, + }); + }); it('returns datasource with inputs', () => { const mockPackageWithDatasources = ({ ...mockPackage, diff --git a/x-pack/plugins/ingest_manager/common/services/package_to_config.ts b/x-pack/plugins/ingest_manager/common/services/package_to_config.ts index 8848fa6a9cf48..fa3479a69e39d 100644 --- a/x-pack/plugins/ingest_manager/common/services/package_to_config.ts +++ b/x-pack/plugins/ingest_manager/common/services/package_to_config.ts @@ -88,10 +88,14 @@ export const packageToConfigDatasource = ( packageInfo: PackageInfo, configId: string, outputId: string, - datasourceName?: string + datasourceName?: string, + namespace?: string, + description?: string ): NewDatasource => { return { name: datasourceName || `${packageInfo.name}-1`, + namespace, + description, package: { name: packageInfo.name, title: packageInfo.title, diff --git a/x-pack/plugins/ingest_manager/server/services/setup.ts b/x-pack/plugins/ingest_manager/server/services/setup.ts index 7311c46320f40..224355ced7cb1 100644 --- a/x-pack/plugins/ingest_manager/server/services/setup.ts +++ b/x-pack/plugins/ingest_manager/server/services/setup.ts @@ -11,7 +11,7 @@ import { agentConfigService } from './agent_config'; import { outputService } from './output'; import { ensureInstalledDefaultPackages } from './epm/packages/install'; import { - packageToConfigDatasourceInputs, + packageToConfigDatasource, Datasource, AgentConfig, Installation, @@ -122,16 +122,8 @@ async function addPackageToConfig( savedObjectsClient: soClient, pkgkey: `${packageToInstall.name}-${packageToInstall.version}`, }); - await datasourceService.create(soClient, { - name: `${packageInfo.name}-1`, - enabled: true, - package: { - name: packageInfo.name, - title: packageInfo.title, - version: packageInfo.version, - }, - inputs: packageToConfigDatasourceInputs(packageInfo), - config_id: config.id, - output_id: defaultOutput.id, - }); + await datasourceService.create( + soClient, + packageToConfigDatasource(packageInfo, config.id, defaultOutput.id, undefined, config.namespace) + ); } From d32c4c8390fe7db4c5d0ebad99389be8c90d1d60 Mon Sep 17 00:00:00 2001 From: Nathan Reese <reese.nathan@gmail.com> Date: Mon, 23 Mar 2020 13:40:24 -0600 Subject: [PATCH 036/179] [Maps] fix point to point source regression (#60930) * [Maps] fix pew pew regression * add functional test for pew pew source --- .../es_search_source/es_search_source.js | 5 ++- .../maps/public/layers/sources/es_source.js | 4 +- .../functional/apps/maps/es_pew_pew_source.js | 39 +++++++++++++++++++ x-pack/test/functional/apps/maps/index.js | 1 + 4 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 x-pack/test/functional/apps/maps/es_pew_pew_source.js diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js index 440b9aa89a945..cd44ef49623fa 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js @@ -149,11 +149,14 @@ export class ESSearchSource extends AbstractESSource { } renderSourceSettingsEditor({ onChange }) { + const getGeoField = () => { + return this._getGeoField(); + }; return ( <UpdateSourceEditor source={this} indexPatternId={this.getIndexPatternId()} - getGeoField={this._getGeoField} + getGeoField={getGeoField} onChange={onChange} tooltipFields={this._tooltipFields} sortField={this._descriptor.sortField} diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.js index 8b079b5202f7f..9dc3067a70436 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.js @@ -231,7 +231,7 @@ export class AbstractESSource extends AbstractVectorSource { } } - _getGeoField = async () => { + async _getGeoField() { const indexPattern = await this.getIndexPattern(); const geoField = indexPattern.fields.getByName(this._descriptor.geoField); if (!geoField) { @@ -243,7 +243,7 @@ export class AbstractESSource extends AbstractVectorSource { ); } return geoField; - }; + } async getDisplayName() { try { diff --git a/x-pack/test/functional/apps/maps/es_pew_pew_source.js b/x-pack/test/functional/apps/maps/es_pew_pew_source.js new file mode 100644 index 0000000000000..45ae1c69640cf --- /dev/null +++ b/x-pack/test/functional/apps/maps/es_pew_pew_source.js @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +export default function({ getPageObjects, getService }) { + const PageObjects = getPageObjects(['maps']); + const inspector = getService('inspector'); + + const VECTOR_SOURCE_ID = '67c1de2c-2fc5-4425-8983-094b589afe61'; + + describe('point to point source', () => { + before(async () => { + await PageObjects.maps.loadSavedMap('pew pew demo'); + }); + + it('should request source clusters for destination locations', async () => { + await inspector.open(); + await inspector.openInspectorRequestsView(); + const requestStats = await inspector.getTableData(); + const hits = PageObjects.maps.getInspectorStatRowHit(requestStats, 'Hits'); + const totalHits = PageObjects.maps.getInspectorStatRowHit(requestStats, 'Hits (total)'); + await inspector.close(); + + expect(hits).to.equal('0'); + expect(totalHits).to.equal('4'); + }); + + it('should render lines', async () => { + const mapboxStyle = await PageObjects.maps.getMapboxStyle(); + const features = mapboxStyle.sources[VECTOR_SOURCE_ID].data.features; + expect(features.length).to.equal(2); + expect(features[0].geometry.type).to.equal('LineString'); + }); + }); +} diff --git a/x-pack/test/functional/apps/maps/index.js b/x-pack/test/functional/apps/maps/index.js index ae7de986cf867..58c211724b287 100644 --- a/x-pack/test/functional/apps/maps/index.js +++ b/x-pack/test/functional/apps/maps/index.js @@ -41,6 +41,7 @@ export default function({ loadTestFile, getService }) { describe('', function() { this.tags('ciGroup10'); loadTestFile(require.resolve('./es_geo_grid_source')); + loadTestFile(require.resolve('./es_pew_pew_source')); loadTestFile(require.resolve('./joins')); loadTestFile(require.resolve('./add_layer_panel')); loadTestFile(require.resolve('./import_geojson')); From 88d41fa352552fa63940de39418dd85553c63d20 Mon Sep 17 00:00:00 2001 From: Thomas Neirynck <thomas@elastic.co> Date: Mon, 23 Mar 2020 16:09:09 -0400 Subject: [PATCH 037/179] [Maps] Remove client-side scaling of ordinal values (#58528) This removes the rescaling of ordinal values to the [0,1] domain, and modifies the creation of the mapbox-style rules to use the actual RangeStyleMeta-data. This is an important prerequisite for Maps handling tile vector sources. For these sources, Maps does not have access to the raw underlying GeoJson and needs to use the stylemeta directly. --- .../maps/public/layers/heatmap_layer.js | 2 +- .../maps/public/layers/styles/color_utils.js | 25 ++- .../public/layers/styles/color_utils.test.js | 22 +-- .../layers/styles/heatmap/heatmap_style.js | 7 +- .../properties/dynamic_color_property.js | 80 ++++++---- .../properties/dynamic_color_property.test.js | 143 +++++++++++------- .../properties/dynamic_icon_property.js | 4 +- .../dynamic_orientation_property.js | 4 - .../properties/dynamic_size_property.js | 51 ++++--- .../properties/dynamic_style_property.d.ts | 2 - .../properties/dynamic_style_property.js | 34 +---- .../properties/dynamic_text_property.js | 4 - .../properties/label_border_size_property.js | 30 ++-- .../public/layers/styles/vector/style_util.js | 36 +++-- .../layers/styles/vector/style_util.test.js | 28 +--- .../layers/styles/vector/vector_style.js | 14 +- .../functional/apps/maps/mapbox_styles.js | 101 +++++++++---- 17 files changed, 335 insertions(+), 252 deletions(-) diff --git a/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js b/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js index ef78b5afe3a3a..22f7a92c17c51 100644 --- a/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js @@ -72,7 +72,7 @@ export class HeatmapLayer extends VectorLayer { const propertyKey = this._getPropKeyOfSelectedMetric(); const dataBoundToMap = AbstractLayer.getBoundDataForSource(mbMap, this.getId()); if (featureCollection !== dataBoundToMap) { - let max = 0; + let max = 1; //max will be at least one, since counts or sums will be at least one. for (let i = 0; i < featureCollection.features.length; i++) { max = Math.max(featureCollection.features[i].properties[propertyKey], max); } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.js b/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.js index a619eaba21aef..09c7d76db1691 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.js @@ -78,17 +78,26 @@ export function getColorRampCenterColor(colorRampName) { // Returns an array of color stops // [ stop_input_1: number, stop_output_1: color, stop_input_n: number, stop_output_n: color ] -export function getOrdinalColorRampStops(colorRampName, numberColors = GRADIENT_INTERVALS) { +export function getOrdinalColorRampStops(colorRampName, min, max) { if (!colorRampName) { return null; } - return getHexColorRangeStrings(colorRampName, numberColors).reduce( - (accu, stopColor, idx, srcArr) => { - const stopNumber = idx / srcArr.length; // number between 0 and 1, increasing as index increases - return [...accu, stopNumber, stopColor]; - }, - [] - ); + + if (min > max) { + return null; + } + + const hexColors = getHexColorRangeStrings(colorRampName, GRADIENT_INTERVALS); + if (max === min) { + //just return single stop value + return [max, hexColors[hexColors.length - 1]]; + } + + const delta = max - min; + return hexColors.reduce((accu, stopColor, idx, srcArr) => { + const stopNumber = min + (delta * idx) / srcArr.length; + return [...accu, stopNumber, stopColor]; + }, []); } export const COLOR_GRADIENTS = Object.keys(vislibColorMaps).map(colorRampName => ({ diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.test.js index 5a8289ba903f3..9a5ece01d5206 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.test.js @@ -60,26 +60,30 @@ describe('getColorRampCenterColor', () => { }); describe('getColorRampStops', () => { - it('Should create color stops for color ramp', () => { - expect(getOrdinalColorRampStops('Blues')).toEqual([ + it('Should create color stops for custom range', () => { + expect(getOrdinalColorRampStops('Blues', 0, 1000)).toEqual([ 0, '#f7faff', - 0.125, + 125, '#ddeaf7', - 0.25, + 250, '#c5daee', - 0.375, + 375, '#9dc9e0', - 0.5, + 500, '#6aadd5', - 0.625, + 625, '#4191c5', - 0.75, + 750, '#2070b4', - 0.875, + 875, '#072f6b', ]); }); + + it('Should snap to end of color stops for identical range', () => { + expect(getOrdinalColorRampStops('Blues', 23, 23)).toEqual([23, '#072f6b']); + }); }); describe('getLinearGradient', () => { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/heatmap/heatmap_style.js b/x-pack/legacy/plugins/maps/public/layers/styles/heatmap/heatmap_style.js index dc3cfc3ffbdb8..d769fe0da9ec2 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/heatmap/heatmap_style.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/heatmap/heatmap_style.js @@ -14,6 +14,11 @@ import { getOrdinalColorRampStops } from '../color_utils'; import { i18n } from '@kbn/i18n'; import { EuiIcon } from '@elastic/eui'; +//The heatmap range chosen hear runs from 0 to 1. It is arbitrary. +//Weighting is on the raw count/sum values. +const MIN_RANGE = 0; +const MAX_RANGE = 1; + export class HeatmapStyle extends AbstractStyle { static type = LAYER_STYLE_TYPE.HEATMAP; @@ -80,7 +85,7 @@ export class HeatmapStyle extends AbstractStyle { const { colorRampName } = this._descriptor; if (colorRampName && colorRampName !== DEFAULT_HEATMAP_COLOR_RAMP_NAME) { - const colorStops = getOrdinalColorRampStops(colorRampName); + const colorStops = getOrdinalColorRampStops(colorRampName, MIN_RANGE, MAX_RANGE); mbMap.setPaintProperty(layerId, 'heatmap-color', [ 'interpolate', ['linear'], diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js index 417426f12fc98..146bc40aa8531 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js @@ -5,8 +5,7 @@ */ import { DynamicStyleProperty } from './dynamic_style_property'; -import _ from 'lodash'; -import { getComputedFieldName, getOtherCategoryLabel } from '../style_util'; +import { getOtherCategoryLabel, makeMbClampedNumberExpression } from '../style_util'; import { getOrdinalColorRampStops, getColorPalette } from '../../color_utils'; import { ColorGradient } from '../../components/color_gradient'; import React from 'react'; @@ -23,6 +22,7 @@ import { COLOR_MAP_TYPE } from '../../../../../common/constants'; import { isCategoricalStopsInvalid } from '../components/color/color_stops_utils'; const EMPTY_STOPS = { stops: [], defaultColor: null }; +const RGBA_0000 = 'rgba(0,0,0,0)'; export class DynamicColorProperty extends DynamicStyleProperty { syncCircleColorWithMb(mbLayerId, mbMap, alpha) { @@ -70,6 +70,17 @@ export class DynamicColorProperty extends DynamicStyleProperty { mbMap.setPaintProperty(mbLayerId, 'text-halo-color', color); } + supportsFieldMeta() { + if (!this.isComplete() || !this._field.supportsFieldMeta()) { + return false; + } + + return ( + (this.isCategorical() && !this._options.useCustomColorPalette) || + (this.isOrdinal() && !this._options.useCustomColorRamp) + ); + } + isOrdinal() { return ( typeof this._options.type === 'undefined' || this._options.type === COLOR_MAP_TYPE.ORDINAL @@ -80,28 +91,20 @@ export class DynamicColorProperty extends DynamicStyleProperty { return this._options.type === COLOR_MAP_TYPE.CATEGORICAL; } - isCustomOrdinalColorRamp() { - return this._options.useCustomColorRamp; - } - supportsMbFeatureState() { return true; } - isOrdinalScaled() { - return this.isOrdinal() && !this.isCustomOrdinalColorRamp(); - } - isOrdinalRanged() { - return this.isOrdinal() && !this.isCustomOrdinalColorRamp(); + return this.isOrdinal() && !this._options.useCustomColorRamp; } hasOrdinalBreaks() { - return (this.isOrdinal() && this.isCustomOrdinalColorRamp()) || this.isCategorical(); + return (this.isOrdinal() && this._options.useCustomColorRamp) || this.isCategorical(); } _getMbColor() { - if (!_.get(this._options, 'field.name')) { + if (!this._field || !this._field.getName()) { return null; } @@ -111,7 +114,7 @@ export class DynamicColorProperty extends DynamicStyleProperty { } _getOrdinalColorMbExpression() { - const targetName = getComputedFieldName(this._styleName, this._options.field.name); + const targetName = this._field.getName(); if (this._options.useCustomColorRamp) { if (!this._options.customColorRamp || !this._options.customColorRamp.length) { // custom color ramp config is not complete @@ -122,27 +125,44 @@ export class DynamicColorProperty extends DynamicStyleProperty { return [...accumulatedStops, nextStop.stop, nextStop.color]; }, []); const firstStopValue = colorStops[0]; - const lessThenFirstStopValue = firstStopValue - 1; + const lessThanFirstStopValue = firstStopValue - 1; return [ 'step', - ['coalesce', ['feature-state', targetName], lessThenFirstStopValue], - 'rgba(0,0,0,0)', // MB will assign the base value to any features that is below the first stop value + ['coalesce', ['feature-state', targetName], lessThanFirstStopValue], + RGBA_0000, // MB will assign the base value to any features that is below the first stop value ...colorStops, ]; - } + } else { + const rangeFieldMeta = this.getRangeFieldMeta(); + if (!rangeFieldMeta) { + return null; + } - const colorStops = getOrdinalColorRampStops(this._options.color); - if (!colorStops) { - return null; + const colorStops = getOrdinalColorRampStops( + this._options.color, + rangeFieldMeta.min, + rangeFieldMeta.max + ); + if (!colorStops) { + return null; + } + + const lessThanFirstStopValue = rangeFieldMeta.min - 1; + return [ + 'interpolate', + ['linear'], + makeMbClampedNumberExpression({ + minValue: rangeFieldMeta.min, + maxValue: rangeFieldMeta.max, + lookupFunction: 'feature-state', + fallback: lessThanFirstStopValue, + fieldName: targetName, + }), + lessThanFirstStopValue, + RGBA_0000, + ...colorStops, + ]; } - return [ - 'interpolate', - ['linear'], - ['coalesce', ['feature-state', targetName], -1], - -1, - 'rgba(0,0,0,0)', - ...colorStops, - ]; } _getColorPaletteStops() { @@ -220,7 +240,7 @@ export class DynamicColorProperty extends DynamicStyleProperty { } mbStops.push(defaultColor); //last color is default color - return ['match', ['to-string', ['get', this._options.field.name]], ...mbStops]; + return ['match', ['to-string', ['get', this._field.getName()]], ...mbStops]; } renderRangeLegendHeader() { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js index 755fc72d52798..b19c25b369848 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js @@ -75,16 +75,10 @@ class MockLayer { } } -const makeProperty = options => { - return new DynamicColorProperty( - options, - VECTOR_STYLES.LINE_COLOR, - mockField, - new MockLayer(), - () => { - return x => x + '_format'; - } - ); +const makeProperty = (options, field = mockField) => { + return new DynamicColorProperty(options, VECTOR_STYLES.LINE_COLOR, field, new MockLayer(), () => { + return x => x + '_format'; + }); }; const defaultLegendParams = { @@ -236,7 +230,72 @@ test('Should pluck the categorical style-meta from fieldmeta', async () => { }); }); -describe('get mapbox color expression', () => { +describe('supportsFieldMeta', () => { + test('should support it when field does for ordinals', () => { + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.ORDINAL, + }; + const styleProp = makeProperty(dynamicStyleOptions); + + expect(styleProp.supportsFieldMeta()).toEqual(true); + }); + + test('should support it when field does for categories', () => { + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.CATEGORICAL, + }; + const styleProp = makeProperty(dynamicStyleOptions); + + expect(styleProp.supportsFieldMeta()).toEqual(true); + }); + + test('should not support it when field does not', () => { + const field = Object.create(mockField); + field.supportsFieldMeta = function() { + return false; + }; + + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.ORDINAL, + }; + const styleProp = makeProperty(dynamicStyleOptions, field); + + expect(styleProp.supportsFieldMeta()).toEqual(false); + }); + + test('should not support it when field config not complete', () => { + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.ORDINAL, + }; + const styleProp = makeProperty(dynamicStyleOptions, null); + + expect(styleProp.supportsFieldMeta()).toEqual(false); + }); + + test('should not support it when using custom ramp for ordinals', () => { + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.ORDINAL, + useCustomColorRamp: true, + customColorRamp: [], + }; + const styleProp = makeProperty(dynamicStyleOptions); + + expect(styleProp.supportsFieldMeta()).toEqual(false); + }); + + test('should not support it when using custom palette for categories', () => { + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.CATEGORICAL, + useCustomColorPalette: true, + customColorPalette: [], + }; + const styleProp = makeProperty(dynamicStyleOptions); + + expect(styleProp.supportsFieldMeta()).toEqual(false); + }); +}); + +describe('get mapbox color expression (via internal _getMbColor)', () => { describe('ordinal color ramp', () => { test('should return null when field is not provided', async () => { const dynamicStyleOptions = { @@ -259,44 +318,46 @@ describe('get mapbox color expression', () => { test('should return null when color ramp is not provided', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.ORDINAL, - field: { - name: 'myField', - }, }; const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toBeNull(); }); - test('should return mapbox expression for color ramp', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.ORDINAL, - field: { - name: 'myField', - }, color: 'Blues', }; const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toEqual([ 'interpolate', ['linear'], - ['coalesce', ['feature-state', '__kbn__dynamic__myField__lineColor'], -1], + [ + 'coalesce', + [ + 'case', + ['==', ['feature-state', 'foobar'], null], + -1, + ['max', ['min', ['to-number', ['feature-state', 'foobar']], 100], 0], + ], + -1, + ], -1, 'rgba(0,0,0,0)', 0, '#f7faff', - 0.125, + 12.5, '#ddeaf7', - 0.25, + 25, '#c5daee', - 0.375, + 37.5, '#9dc9e0', - 0.5, + 50, '#6aadd5', - 0.625, + 62.5, '#4191c5', - 0.75, + 75, '#2070b4', - 0.875, + 87.5, '#072f6b', ]); }); @@ -306,9 +367,6 @@ describe('get mapbox color expression', () => { test('should return null when customColorRamp is not provided', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.ORDINAL, - field: { - name: 'myField', - }, useCustomColorRamp: true, }; const colorProperty = makeProperty(dynamicStyleOptions); @@ -318,9 +376,6 @@ describe('get mapbox color expression', () => { test('should return null when customColorRamp is empty', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.ORDINAL, - field: { - name: 'myField', - }, useCustomColorRamp: true, customColorRamp: [], }; @@ -331,9 +386,6 @@ describe('get mapbox color expression', () => { test('should return mapbox expression for custom color ramp', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.ORDINAL, - field: { - name: 'myField', - }, useCustomColorRamp: true, customColorRamp: [ { stop: 10, color: '#f7faff' }, @@ -343,7 +395,7 @@ describe('get mapbox color expression', () => { const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toEqual([ 'step', - ['coalesce', ['feature-state', '__kbn__dynamic__myField__lineColor'], 9], + ['coalesce', ['feature-state', 'foobar'], 9], 'rgba(0,0,0,0)', 10, '#f7faff', @@ -376,9 +428,6 @@ describe('get mapbox color expression', () => { test('should return null when color palette is not provided', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.CATEGORICAL, - field: { - name: 'myField', - }, }; const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toBeNull(); @@ -387,15 +436,12 @@ describe('get mapbox color expression', () => { test('should return mapbox expression for color palette', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.CATEGORICAL, - field: { - name: 'myField', - }, colorCategory: 'palette_0', }; const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toEqual([ 'match', - ['to-string', ['get', 'myField']], + ['to-string', ['get', 'foobar']], 'US', '#54B399', 'CN', @@ -409,9 +455,6 @@ describe('get mapbox color expression', () => { test('should return null when customColorPalette is not provided', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.CATEGORICAL, - field: { - name: 'myField', - }, useCustomColorPalette: true, }; const colorProperty = makeProperty(dynamicStyleOptions); @@ -421,9 +464,6 @@ describe('get mapbox color expression', () => { test('should return null when customColorPalette is empty', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.CATEGORICAL, - field: { - name: 'myField', - }, useCustomColorPalette: true, customColorPalette: [], }; @@ -434,9 +474,6 @@ describe('get mapbox color expression', () => { test('should return mapbox expression for custom color palette', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.CATEGORICAL, - field: { - name: 'myField', - }, useCustomColorPalette: true, customColorPalette: [ { stop: null, color: '#f7faff' }, @@ -446,7 +483,7 @@ describe('get mapbox color expression', () => { const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toEqual([ 'match', - ['to-string', ['get', 'myField']], + ['to-string', ['get', 'foobar']], 'MX', '#072f6b', '#f7faff', diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js index c492efbdf4ba3..05e2ad06842ce 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js @@ -81,7 +81,7 @@ export class DynamicIconProperty extends DynamicStyleProperty { mbStops.push(getMakiIconId(style, iconPixelSize)); }); mbStops.push(getMakiIconId(fallback, iconPixelSize)); //last item is fallback style for anything that does not match provided stops - return ['match', ['to-string', ['get', this._options.field.name]], ...mbStops]; + return ['match', ['to-string', ['get', this._field.getName()]], ...mbStops]; } _getMbIconAnchorExpression() { @@ -98,7 +98,7 @@ export class DynamicIconProperty extends DynamicStyleProperty { mbStops.push(getMakiSymbolAnchor(style)); }); mbStops.push(getMakiSymbolAnchor(fallback)); //last item is fallback style for anything that does not match provided stops - return ['match', ['to-string', ['get', this._options.field.name]], ...mbStops]; + return ['match', ['to-string', ['get', this._field.getName()]], ...mbStops]; } _isIconDynamicConfigComplete() { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js index 96408b3d2229e..ae4d935e2457b 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js @@ -25,8 +25,4 @@ export class DynamicOrientationProperty extends DynamicStyleProperty { supportsMbFeatureState() { return false; } - - isOrdinalScaled() { - return false; - } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js index 7626f8c9b4158..8b3f670bfa528 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js @@ -5,7 +5,7 @@ */ import { DynamicStyleProperty } from './dynamic_style_property'; - +import { makeMbClampedNumberExpression } from '../style_util'; import { HALF_LARGE_MAKI_ICON_SIZE, LARGE_MAKI_ICON_SIZE, @@ -74,17 +74,24 @@ export class DynamicSizeProperty extends DynamicStyleProperty { } syncIconSizeWithMb(symbolLayerId, mbMap) { - if (this._isSizeDynamicConfigComplete(this._options)) { + const rangeFieldMeta = this.getRangeFieldMeta(); + if (this._isSizeDynamicConfigComplete(this._options) && rangeFieldMeta) { const halfIconPixels = this.getIconPixelSize() / 2; - const targetName = this.getComputedFieldName(); + const targetName = this.getFieldName(); // Using property state instead of feature-state because layout properties do not support feature-state mbMap.setLayoutProperty(symbolLayerId, 'icon-size', [ 'interpolate', ['linear'], - ['coalesce', ['get', targetName], 0], - 0, + makeMbClampedNumberExpression({ + minValue: rangeFieldMeta.min, + maxValue: rangeFieldMeta.max, + fallback: 0, + lookupFunction: 'get', + fieldName: targetName, + }), + rangeFieldMeta.min, this._options.minSize / halfIconPixels, - 1, + rangeFieldMeta.max, this._options.maxSize / halfIconPixels, ]); } else { @@ -113,25 +120,35 @@ export class DynamicSizeProperty extends DynamicStyleProperty { } getMbSizeExpression() { - if (this._isSizeDynamicConfigComplete(this._options)) { - return this._getMbDataDrivenSize({ - targetName: this.getComputedFieldName(), - minSize: this._options.minSize, - maxSize: this._options.maxSize, - }); + const rangeFieldMeta = this.getRangeFieldMeta(); + if (!this._isSizeDynamicConfigComplete(this._options) || !rangeFieldMeta) { + return null; } - return null; + + return this._getMbDataDrivenSize({ + targetName: this.getFieldName(), + minSize: this._options.minSize, + maxSize: this._options.maxSize, + minValue: rangeFieldMeta.min, + maxValue: rangeFieldMeta.max, + }); } - _getMbDataDrivenSize({ targetName, minSize, maxSize }) { + _getMbDataDrivenSize({ targetName, minSize, maxSize, minValue, maxValue }) { const lookup = this.supportsMbFeatureState() ? 'feature-state' : 'get'; return [ 'interpolate', ['linear'], - ['coalesce', [lookup, targetName], 0], - 0, + makeMbClampedNumberExpression({ + lookupFunction: lookup, + maxValue, + minValue, + fieldName: targetName, + fallback: 0, + }), + minValue, minSize, - 1, + maxValue, maxSize, ]; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts index 25063944b8891..a83dd55c0c175 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts @@ -12,8 +12,6 @@ import { DynamicStylePropertyOptions, } from '../../../../../common/style_property_descriptor_types'; import { IField } from '../../../fields/field'; -import { IVectorLayer } from '../../../vector_layer'; -import { IVectorSource } from '../../../sources/vector_source'; import { CategoryFieldMeta, RangeFieldMeta } from '../../../../../common/descriptor_types'; export interface IDynamicStyleProperty extends IStyleProperty { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js index 68e06bacfa7b7..ea521f8749d80 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js @@ -13,7 +13,6 @@ import { SOURCE_META_ID_ORIGIN, FIELD_ORIGIN, } from '../../../../../common/constants'; -import { scaleValue, getComputedFieldName } from '../style_util'; import React from 'react'; import { OrdinalLegend } from './components/ordinal_legend'; import { CategoricalLegend } from './components/categorical_legend'; @@ -109,13 +108,6 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return this._field ? this._field.getName() : ''; } - getComputedFieldName() { - if (!this.isComplete()) { - return null; - } - return getComputedFieldName(this._styleName, this.getField().getName()); - } - isDynamic() { return true; } @@ -150,13 +142,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty { } supportsFieldMeta() { - if (this.isOrdinal()) { - return this.isComplete() && this.isOrdinalScaled() && this._field.supportsFieldMeta(); - } else if (this.isCategorical()) { - return this.isComplete() && this._field.supportsFieldMeta(); - } else { - return false; - } + return this.isComplete() && this._field.supportsFieldMeta(); } async getFieldMetaRequest() { @@ -173,10 +159,6 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return true; } - isOrdinalScaled() { - return true; - } - getFieldMetaOptions() { return _.get(this.getOptions(), 'fieldMetaOptions', {}); } @@ -296,19 +278,9 @@ export class DynamicStyleProperty extends AbstractStyleProperty { } } - getMbValue(value) { - if (!this.isOrdinal()) { - return this.formatField(value); - } - + getNumericalMbFeatureStateValue(value) { const valueAsFloat = parseFloat(value); - if (this.isOrdinalScaled()) { - return scaleValue(valueAsFloat, this.getRangeFieldMeta()); - } - if (isNaN(valueAsFloat)) { - return 0; - } - return valueAsFloat; + return isNaN(valueAsFloat) ? null : valueAsFloat; } renderBreakedLegend() { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js index c561ec128dec5..de868f3f92650 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js @@ -28,8 +28,4 @@ export class DynamicTextProperty extends DynamicStyleProperty { supportsMbFeatureState() { return false; } - - isOrdinalScaled() { - return false; - } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js index 7119b659c1232..3016b15d0a05c 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js @@ -31,21 +31,27 @@ export class LabelBorderSizeProperty extends AbstractStyleProperty { } syncLabelBorderSizeWithMb(mbLayerId, mbMap) { - const widthRatio = getWidthRatio(this.getOptions().size); - if (this.getOptions().size === LABEL_BORDER_SIZES.NONE) { mbMap.setPaintProperty(mbLayerId, 'text-halo-width', 0); - } else if (this._labelSizeProperty.isDynamic() && this._labelSizeProperty.isComplete()) { + return; + } + + const widthRatio = getWidthRatio(this.getOptions().size); + + if (this._labelSizeProperty.isDynamic() && this._labelSizeProperty.isComplete()) { const labelSizeExpression = this._labelSizeProperty.getMbSizeExpression(); - mbMap.setPaintProperty(mbLayerId, 'text-halo-width', [ - 'max', - ['*', labelSizeExpression, widthRatio], - 1, - ]); - } else { - const labelSize = _.get(this._labelSizeProperty.getOptions(), 'size', DEFAULT_LABEL_SIZE); - const labelBorderSize = Math.max(labelSize * widthRatio, 1); - mbMap.setPaintProperty(mbLayerId, 'text-halo-width', labelBorderSize); + if (labelSizeExpression) { + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', [ + 'max', + ['*', labelSizeExpression, widthRatio], + 1, + ]); + return; + } } + + const labelSize = _.get(this._labelSizeProperty.getOptions(), 'size', DEFAULT_LABEL_SIZE); + const labelBorderSize = Math.max(labelSize * widthRatio, 1); + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', labelBorderSize); } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js index 2859b8c0e5a56..0820568468439 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js @@ -34,22 +34,6 @@ export function isOnlySingleFeatureType(featureType, supportedFeatures, hasFeatu }, true); } -export function scaleValue(value, range) { - if (isNaN(value) || !range) { - return -1; //Nothing to scale, put outside scaled range - } - - if (range.delta === 0 || value >= range.max) { - return 1; //snap to end of scaled range - } - - if (value <= range.min) { - return 0; //snap to beginning of scaled range - } - - return (value - range.min) / range.delta; -} - export function assignCategoriesToPalette({ categories, paletteValues }) { const stops = []; let fallback = null; @@ -70,3 +54,23 @@ export function assignCategoriesToPalette({ categories, paletteValues }) { fallback, }; } + +export function makeMbClampedNumberExpression({ + lookupFunction, + fieldName, + minValue, + maxValue, + fallback, +}) { + const clamp = ['max', ['min', ['to-number', [lookupFunction, fieldName]], maxValue], minValue]; + return [ + 'coalesce', + [ + 'case', + ['==', [lookupFunction, fieldName], null], + minValue - 1, //== does a JS-y like check where returns true for null and undefined + clamp, + ], + fallback, + ]; +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.test.js index 2be31c0107193..76bbfc84e3892 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.test.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isOnlySingleFeatureType, scaleValue, assignCategoriesToPalette } from './style_util'; +import { isOnlySingleFeatureType, assignCategoriesToPalette } from './style_util'; import { VECTOR_SHAPE_TYPES } from '../../sources/vector_feature_types'; describe('isOnlySingleFeatureType', () => { @@ -62,32 +62,6 @@ describe('isOnlySingleFeatureType', () => { }); }); -describe('scaleValue', () => { - test('Should scale value between 0 and 1', () => { - expect(scaleValue(5, { min: 0, max: 10, delta: 10 })).toBe(0.5); - }); - - test('Should snap value less then range min to 0', () => { - expect(scaleValue(-1, { min: 0, max: 10, delta: 10 })).toBe(0); - }); - - test('Should snap value greater then range max to 1', () => { - expect(scaleValue(11, { min: 0, max: 10, delta: 10 })).toBe(1); - }); - - test('Should snap value to 1 when tere is not range delta', () => { - expect(scaleValue(10, { min: 10, max: 10, delta: 0 })).toBe(1); - }); - - test('Should put value as -1 when value is not provided', () => { - expect(scaleValue(undefined, { min: 0, max: 10, delta: 10 })).toBe(-1); - }); - - test('Should put value as -1 when range is not provided', () => { - expect(scaleValue(5, undefined)).toBe(-1); - }); -}); - describe('assignCategoriesToPalette', () => { test('Categories and palette values have same length', () => { const categories = [{ key: 'alpah' }, { key: 'bravo' }, { key: 'charlie' }, { key: 'delta' }]; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js index b5a0b51727936..ae5d148e43cfd 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js @@ -503,11 +503,19 @@ export class VectorStyle extends AbstractStyle { const dynamicStyleProp = dynamicStyleProps[j]; const name = dynamicStyleProp.getField().getName(); const computedName = getComputedFieldName(dynamicStyleProp.getStyleName(), name); - const styleValue = dynamicStyleProp.getMbValue(feature.properties[name]); + const rawValue = feature.properties[name]; if (dynamicStyleProp.supportsMbFeatureState()) { - tmpFeatureState[computedName] = styleValue; + tmpFeatureState[name] = dynamicStyleProp.getNumericalMbFeatureStateValue(rawValue); //the same value will be potentially overridden multiple times, if the name remains identical } else { - feature.properties[computedName] = styleValue; + //in practice, a new system property will only be created for: + // - label text: this requires the value to be formatted first. + // - icon orientation: this is a lay-out property which do not support feature-state (but we're still coercing to a number) + + const formattedValue = dynamicStyleProp.isOrdinal() + ? dynamicStyleProp.getNumericalMbFeatureStateValue(rawValue) + : dynamicStyleProp.formatField(rawValue); + + feature.properties[computedName] = formattedValue; } } tmpFeatureIdentifier.source = mbSourceId; diff --git a/x-pack/test/functional/apps/maps/mapbox_styles.js b/x-pack/test/functional/apps/maps/mapbox_styles.js index d3280bae8582e..508a019db1764 100644 --- a/x-pack/test/functional/apps/maps/mapbox_styles.js +++ b/x-pack/test/functional/apps/maps/mapbox_styles.js @@ -16,9 +16,7 @@ export const MAPBOX_STYLES = { ['==', ['get', '__kbn_isvisibleduetojoin__'], true], ['any', ['==', ['geometry-type'], 'Point'], ['==', ['geometry-type'], 'MultiPoint']], ], - layout: { - visibility: 'visible', - }, + layout: { visibility: 'visible' }, paint: { 'circle-color': [ 'interpolate', @@ -26,32 +24,53 @@ export const MAPBOX_STYLES = { [ 'coalesce', [ - 'feature-state', - '__kbn__dynamic____kbnjoin__max_of_prop1_groupby_meta_for_geo_shapes*.shape_name__fillColor', + 'case', + [ + '==', + ['feature-state', '__kbnjoin__max_of_prop1_groupby_meta_for_geo_shapes*.shape_name'], + null, + ], + 2, + [ + 'max', + [ + 'min', + [ + 'to-number', + [ + 'feature-state', + '__kbnjoin__max_of_prop1_groupby_meta_for_geo_shapes*.shape_name', + ], + ], + 12, + ], + 3, + ], ], - -1, + 2, ], - -1, + 2, 'rgba(0,0,0,0)', - 0, + 3, '#f7faff', - 0.125, + 4.125, '#ddeaf7', - 0.25, + 5.25, '#c5daee', - 0.375, + 6.375, '#9dc9e0', - 0.5, + 7.5, '#6aadd5', - 0.625, + 8.625, '#4191c5', - 0.75, + 9.75, '#2070b4', - 0.875, + 10.875, '#072f6b', ], - 'circle-opacity': 0.75, // Obtained dynamically - /* 'circle-stroke-color': '' */ 'circle-stroke-opacity': 0.75, + 'circle-opacity': 0.75, + 'circle-stroke-color': '#41937c', + 'circle-stroke-opacity': 0.75, 'circle-stroke-width': 1, 'circle-radius': 10, }, @@ -67,9 +86,7 @@ export const MAPBOX_STYLES = { ['==', ['get', '__kbn_isvisibleduetojoin__'], true], ['any', ['==', ['geometry-type'], 'Polygon'], ['==', ['geometry-type'], 'MultiPolygon']], ], - layout: { - visibility: 'visible', - }, + layout: { visibility: 'visible' }, paint: { 'fill-color': [ 'interpolate', @@ -77,28 +94,48 @@ export const MAPBOX_STYLES = { [ 'coalesce', [ - 'feature-state', - '__kbn__dynamic____kbnjoin__max_of_prop1_groupby_meta_for_geo_shapes*.shape_name__fillColor', + 'case', + [ + '==', + ['feature-state', '__kbnjoin__max_of_prop1_groupby_meta_for_geo_shapes*.shape_name'], + null, + ], + 2, + [ + 'max', + [ + 'min', + [ + 'to-number', + [ + 'feature-state', + '__kbnjoin__max_of_prop1_groupby_meta_for_geo_shapes*.shape_name', + ], + ], + 12, + ], + 3, + ], ], - -1, + 2, ], - -1, + 2, 'rgba(0,0,0,0)', - 0, + 3, '#f7faff', - 0.125, + 4.125, '#ddeaf7', - 0.25, + 5.25, '#c5daee', - 0.375, + 6.375, '#9dc9e0', - 0.5, + 7.5, '#6aadd5', - 0.625, + 8.625, '#4191c5', - 0.75, + 9.75, '#2070b4', - 0.875, + 10.875, '#072f6b', ], 'fill-opacity': 0.75, From 3eeb8df172d66911b51e66009b1b53ed39aa91da Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet <nicolas.chaulet@elastic.co> Date: Mon, 23 Mar 2020 16:40:33 -0400 Subject: [PATCH 038/179] [Fleet] add Agent config details yaml view (#60943) --- .../ingest_manager/common/services/routes.ts | 4 + .../enrollment_instructions/index.tsx | 0 .../enrollment_instructions/manual/index.tsx | 0 .../enrollment_instructions/shell/index.tsx | 14 ++-- .../hooks/use_request/agent_config.ts | 7 ++ .../details_page/components/yaml/index.tsx | 78 +++++++++++++++++++ .../agent_config/details_page/index.tsx | 4 +- .../agent_enrollment_flyout/instructions.tsx | 5 +- 8 files changed, 103 insertions(+), 9 deletions(-) rename x-pack/plugins/ingest_manager/public/applications/ingest_manager/{sections/fleet/agent_list_page => }/components/enrollment_instructions/index.tsx (100%) rename x-pack/plugins/ingest_manager/public/applications/ingest_manager/{sections/fleet/agent_list_page => }/components/enrollment_instructions/manual/index.tsx (100%) rename x-pack/plugins/ingest_manager/public/applications/ingest_manager/{sections/fleet/agent_list_page => }/components/enrollment_instructions/shell/index.tsx (86%) create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/yaml/index.tsx diff --git a/x-pack/plugins/ingest_manager/common/services/routes.ts b/x-pack/plugins/ingest_manager/common/services/routes.ts index 01b3b1983486c..7cc6fc3c66afb 100644 --- a/x-pack/plugins/ingest_manager/common/services/routes.ts +++ b/x-pack/plugins/ingest_manager/common/services/routes.ts @@ -82,6 +82,10 @@ export const agentConfigRouteService = { getDeletePath: () => { return AGENT_CONFIG_API_ROUTES.DELETE_PATTERN; }, + + getInfoFullPath: (agentConfigId: string) => { + return AGENT_CONFIG_API_ROUTES.FULL_INFO_PATTERN.replace('{agentConfigId}', agentConfigId); + }, }; export const fleetSetupRouteService = { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/index.tsx similarity index 100% rename from x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/index.tsx rename to x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/index.tsx diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/manual/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx similarity index 100% rename from x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/manual/index.tsx rename to x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/shell/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/shell/index.tsx similarity index 86% rename from x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/shell/index.tsx rename to x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/shell/index.tsx index 04e84902bc9d4..e6990927b926e 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/shell/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/shell/index.tsx @@ -12,7 +12,7 @@ import { EuiFieldText, EuiPopover, } from '@elastic/eui'; -import { EnrollmentAPIKey } from '../../../../../../types'; +import { EnrollmentAPIKey } from '../../../types'; // No need for i18n as these are platform names const PLATFORMS = { @@ -37,11 +37,13 @@ export const ShellEnrollmentInstructions: React.FunctionComponent<Props> = ({ const [isPlatformOptionsOpen, setIsPlatformOptionsOpen] = useState<boolean>(false); // Build quick installation command - const quickInstallInstructions = `${ - kibanaCASha256 ? `CA_SHA256=${kibanaCASha256} ` : '' - }API_KEY=${ - apiKey.api_key - } sh -c "$(curl ${kibanaUrl}/api/ingest_manager/fleet/install/${currentPlatform})"`; + // const quickInstallInstructions = `${ + // kibanaCASha256 ? `CA_SHA256=${kibanaCASha256} ` : '' + // }API_KEY=${ + // apiKey.api_key + // } sh -c "$(curl ${kibanaUrl}/api/ingest_manager/fleet/install/${currentPlatform})"`; + + const quickInstallInstructions = `./agent enroll ${kibanaUrl} ${apiKey.api_key}`; return ( <> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts index d44cc67e2dc4c..d16d835f8c701 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts @@ -32,6 +32,13 @@ export const useGetOneAgentConfig = (agentConfigId: string) => { }); }; +export const useGetOneAgentConfigFull = (agentConfigId: string) => { + return useRequest({ + path: agentConfigRouteService.getInfoFullPath(agentConfigId), + method: 'get', + }); +}; + export const sendGetOneAgentConfig = (agentConfigId: string) => { return sendRequest<GetOneAgentConfigResponse>({ path: agentConfigRouteService.getInfoPath(agentConfigId), diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/yaml/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/yaml/index.tsx new file mode 100644 index 0000000000000..57031a3d72abe --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/yaml/index.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { dump } from 'js-yaml'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiSpacer, + EuiText, + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { AgentConfig } from '../../../../../../../../common/types/models'; +import { + useGetOneAgentConfigFull, + useGetEnrollmentAPIKeys, + useGetOneEnrollmentAPIKey, + useCore, +} from '../../../../../hooks'; +import { ShellEnrollmentInstructions } from '../../../../../components/enrollment_instructions'; +import { Loading } from '../../../../../components'; + +const CONFIG_KEYS_ORDER = ['id', 'revision', 'outputs', 'datasources']; + +export const ConfigYamlView = memo<{ config: AgentConfig }>(({ config }) => { + const core = useCore(); + + const fullConfigRequest = useGetOneAgentConfigFull(config.id); + const apiKeysRequest = useGetEnrollmentAPIKeys(); + const apiKeyRequest = useGetOneEnrollmentAPIKey(apiKeysRequest.data?.list?.[0].id as string); + + if (fullConfigRequest.isLoading && !fullConfigRequest.data) { + return <Loading />; + } + + return ( + <EuiFlexGroup> + <EuiFlexItem grow={7}> + <EuiCodeBlock language="yaml" isCopyable> + {dump(fullConfigRequest.data.item, { + sortKeys: (keyA: string, keyB: string) => { + return CONFIG_KEYS_ORDER.indexOf(keyA) - CONFIG_KEYS_ORDER.indexOf(keyB); + }, + })} + </EuiCodeBlock> + </EuiFlexItem> + <EuiFlexItem grow={3}> + <EuiTitle size="s"> + <h3> + <FormattedMessage + id="xpack.ingestManager.yamlConfig.instructionTittle" + defaultMessage="Enroll with fleet" + /> + </h3> + </EuiTitle> + <EuiSpacer size="m" /> + <EuiText size="s"> + <FormattedMessage + id="xpack.ingestManager.yamlConfig.instructionDescription" + defaultMessage="To enroll an agent with this configuration, copy and run the following command on your host." + /> + </EuiText> + <EuiSpacer size="m" /> + {apiKeyRequest.data && ( + <ShellEnrollmentInstructions + apiKey={apiKeyRequest.data.item} + kibanaUrl={`${window.location.origin}${core.http.basePath.get()}`} + /> + )} + </EuiFlexItem> + </EuiFlexGroup> + ); +}); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx index efb96f6459254..450f86df5c03a 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx @@ -31,6 +31,7 @@ import { LinkedAgentCount } from '../components'; import { useAgentConfigLink } from './hooks/use_details_uri'; import { DETAILS_ROUTER_PATH, DETAILS_ROUTER_SUB_PATH } from './constants'; import { ConfigDatasourcesView } from './components/datasources'; +import { ConfigYamlView } from './components/yaml'; const Divider = styled.div` width: 0; @@ -282,8 +283,7 @@ export const AgentConfigDetailsLayout: React.FunctionComponent = () => { <Route path={`${DETAILS_ROUTER_PATH}/yaml`} render={() => { - // TODO: YAML implementation tracked via https://github.com/elastic/kibana/issues/57958 - return <div>YAML placeholder</div>; + return <ConfigYamlView config={agentConfig} />; }} /> <Route diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx index 97434d2178852..1bc20c2baf660 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx @@ -8,7 +8,10 @@ import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiText, EuiButtonGroup, EuiSteps } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { useEnrollmentApiKey } from '../enrollment_api_keys'; -import { ShellEnrollmentInstructions, ManualInstructions } from '../enrollment_instructions'; +import { + ShellEnrollmentInstructions, + ManualInstructions, +} from '../../../../../components/enrollment_instructions'; import { useCore, useGetAgents } from '../../../../../hooks'; import { Loading } from '../../../components'; From 3c924d9f8723e088958aae3fbbe601e3dd926198 Mon Sep 17 00:00:00 2001 From: Wylie Conlon <william.conlon@elastic.co> Date: Mon, 23 Mar 2020 16:44:57 -0400 Subject: [PATCH 039/179] [Lens] Use new charts APIs to simplify series naming (#60708) * build: update @elastic/charts to v18.1.0 * tests: fix breaking-change on legendItem className * fix: type changes and ml custom tooltip data * tests: fix snapshot test * [Lens] Use new charts APIs to simplify series naming * Fix types * Fix naming * Remove accidental file * Update snapshots Co-authored-by: Marco Vettorello <vettorello.marco@gmail.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../__snapshots__/xy_expression.test.tsx.snap | 119 +++++++++--------- .../xy_visualization/xy_expression.test.tsx | 48 +++++-- .../public/xy_visualization/xy_expression.tsx | 83 ++++-------- 3 files changed, 121 insertions(+), 129 deletions(-) diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap b/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap index 44398c929a2b9..4b19ad288ddaa 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap +++ b/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap @@ -29,24 +29,23 @@ exports[`xy_expression XYChart component it renders area 1`] = ` data={ Array [ Object { - "Label A": 1, - "Label B": 2, - "Label D": "Foo", + "a": 1, + "b": 2, "c": "I", "d": "Foo", }, Object { - "Label A": 1, - "Label B": 5, - "Label D": "Bar", + "a": 1, + "b": 5, "c": "J", "d": "Bar", }, ] } enableHistogramMode={false} - id="Label D" + id="d" key="0" + name={[Function]} splitSeriesAccessors={ Array [ "d", @@ -58,8 +57,8 @@ exports[`xy_expression XYChart component it renders area 1`] = ` xScaleType="ordinal" yAccessors={ Array [ - "Label A", - "Label B", + "a", + "b", ] } yScaleType="linear" @@ -96,24 +95,23 @@ exports[`xy_expression XYChart component it renders bar 1`] = ` data={ Array [ Object { - "Label A": 1, - "Label B": 2, - "Label D": "Foo", + "a": 1, + "b": 2, "c": "I", "d": "Foo", }, Object { - "Label A": 1, - "Label B": 5, - "Label D": "Bar", + "a": 1, + "b": 5, "c": "J", "d": "Bar", }, ] } enableHistogramMode={false} - id="Label D" + id="d" key="0" + name={[Function]} splitSeriesAccessors={ Array [ "d", @@ -125,8 +123,8 @@ exports[`xy_expression XYChart component it renders bar 1`] = ` xScaleType="ordinal" yAccessors={ Array [ - "Label A", - "Label B", + "a", + "b", ] } yScaleType="linear" @@ -163,24 +161,23 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = ` data={ Array [ Object { - "Label A": 1, - "Label B": 2, - "Label D": "Foo", + "a": 1, + "b": 2, "c": "I", "d": "Foo", }, Object { - "Label A": 1, - "Label B": 5, - "Label D": "Bar", + "a": 1, + "b": 5, "c": "J", "d": "Bar", }, ] } enableHistogramMode={false} - id="Label D" + id="d" key="0" + name={[Function]} splitSeriesAccessors={ Array [ "d", @@ -192,8 +189,8 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = ` xScaleType="ordinal" yAccessors={ Array [ - "Label A", - "Label B", + "a", + "b", ] } yScaleType="linear" @@ -230,24 +227,23 @@ exports[`xy_expression XYChart component it renders line 1`] = ` data={ Array [ Object { - "Label A": 1, - "Label B": 2, - "Label D": "Foo", + "a": 1, + "b": 2, "c": "I", "d": "Foo", }, Object { - "Label A": 1, - "Label B": 5, - "Label D": "Bar", + "a": 1, + "b": 5, "c": "J", "d": "Bar", }, ] } enableHistogramMode={false} - id="Label D" + id="d" key="0" + name={[Function]} splitSeriesAccessors={ Array [ "d", @@ -259,8 +255,8 @@ exports[`xy_expression XYChart component it renders line 1`] = ` xScaleType="ordinal" yAccessors={ Array [ - "Label A", - "Label B", + "a", + "b", ] } yScaleType="linear" @@ -297,24 +293,23 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = ` data={ Array [ Object { - "Label A": 1, - "Label B": 2, - "Label D": "Foo", + "a": 1, + "b": 2, "c": "I", "d": "Foo", }, Object { - "Label A": 1, - "Label B": 5, - "Label D": "Bar", + "a": 1, + "b": 5, "c": "J", "d": "Bar", }, ] } enableHistogramMode={false} - id="Label D" + id="d" key="0" + name={[Function]} splitSeriesAccessors={ Array [ "d", @@ -330,8 +325,8 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = ` xScaleType="ordinal" yAccessors={ Array [ - "Label A", - "Label B", + "a", + "b", ] } yScaleType="linear" @@ -368,24 +363,23 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = ` data={ Array [ Object { - "Label A": 1, - "Label B": 2, - "Label D": "Foo", + "a": 1, + "b": 2, "c": "I", "d": "Foo", }, Object { - "Label A": 1, - "Label B": 5, - "Label D": "Bar", + "a": 1, + "b": 5, "c": "J", "d": "Bar", }, ] } enableHistogramMode={false} - id="Label D" + id="d" key="0" + name={[Function]} splitSeriesAccessors={ Array [ "d", @@ -401,8 +395,8 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = ` xScaleType="ordinal" yAccessors={ Array [ - "Label A", - "Label B", + "a", + "b", ] } yScaleType="linear" @@ -439,24 +433,23 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] = data={ Array [ Object { - "Label A": 1, - "Label B": 2, - "Label D": "Foo", + "a": 1, + "b": 2, "c": "I", "d": "Foo", }, Object { - "Label A": 1, - "Label B": 5, - "Label D": "Bar", + "a": 1, + "b": 5, "c": "J", "d": "Bar", }, ] } enableHistogramMode={false} - id="Label D" + id="d" key="0" + name={[Function]} splitSeriesAccessors={ Array [ "d", @@ -472,8 +465,8 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] = xScaleType="ordinal" yAccessors={ Array [ - "Label A", - "Label B", + "a", + "b", ] } yScaleType="linear" diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx index 6323a063c988b..adf64fece2942 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx @@ -12,6 +12,7 @@ import { LineSeries, Settings, ScaleType, + SeriesNameFn, } from '@elastic/charts'; import { xyChart, XYChart } from './xy_expression'; import { LensMultiTable } from '../types'; @@ -367,7 +368,7 @@ describe('xy_expression', () => { expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(false); }); - test('it rewrites the rows based on provided labels', () => { + test('it names the series for multiple accessors', () => { const { data, args } = sampleArgs(); const component = shallow( @@ -379,25 +380,56 @@ describe('xy_expression', () => { chartTheme={{}} /> ); - expect(component.find(LineSeries).prop('data')).toEqual([ - { 'Label A': 1, 'Label B': 2, c: 'I', 'Label D': 'Foo', d: 'Foo' }, - { 'Label A': 1, 'Label B': 5, c: 'J', 'Label D': 'Bar', d: 'Bar' }, - ]); + const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + + expect( + nameFn( + { + seriesKeys: ['a', 'b', 'c', 'd'], + key: '', + specId: 'a', + yAccessor: '', + splitAccessors: new Map(), + }, + false + ) + ).toEqual('Label A - Label B - c - Label D'); }); - test('it uses labels as Y accessors', () => { + test('it names the series for a single accessor', () => { const { data, args } = sampleArgs(); const component = shallow( <XYChart data={data} - args={args} + args={{ + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['a'], + }, + ], + }} formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} /> ); - expect(component.find(LineSeries).prop('yAccessors')).toEqual(['Label A', 'Label B']); + const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + + expect( + nameFn( + { + seriesKeys: ['a', 'b', 'c', 'd'], + key: '', + specId: 'a', + yAccessor: '', + splitAccessors: new Map(), + }, + false + ) + ).toEqual('Label A'); }); test('it set the scale of the x axis according to the args prop', () => { diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx index cc30e5d18a7f7..ce966ee6150a0 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -18,7 +18,6 @@ import { } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { - KibanaDatatable, IInterpreterRenderHandlers, ExpressionRenderDefinition, ExpressionFunctionDefinition, @@ -33,6 +32,11 @@ import { XYArgs, SeriesType, visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; import { isHorizontalChart } from './state_helpers'; +type InferPropType<T> = T extends React.FunctionComponent<infer P> ? P : T; +type SeriesSpec = InferPropType<typeof LineSeries> & + InferPropType<typeof BarSeries> & + InferPropType<typeof AreaSeries>; + export interface XYChartProps { data: LensMultiTable; args: XYArgs; @@ -247,80 +251,43 @@ export function XYChart({ data, args, formatFactory, timeZone, chartTheme }: XYC return; } - const columnToLabelMap = columnToLabel ? JSON.parse(columnToLabel) : {}; - const splitAccessorLabel = splitAccessor ? columnToLabelMap[splitAccessor] : ''; - const yAccessors = accessors.map(accessor => columnToLabelMap[accessor] || accessor); - const idForLegend = splitAccessorLabel || yAccessors; - const sanitized = sanitizeRows({ - splitAccessor, - formatFactory, - columnToLabelMap, - table: data.tables[layerId], - }); - - const seriesProps = { - key: index, - splitSeriesAccessors: sanitized.splitAccessor ? [sanitized.splitAccessor] : [], + const columnToLabelMap: Record<string, string> = columnToLabel + ? JSON.parse(columnToLabel) + : {}; + const table = data.tables[layerId]; + const seriesProps: SeriesSpec = { + splitSeriesAccessors: splitAccessor ? [splitAccessor] : [], stackAccessors: seriesType.includes('stacked') ? [xAccessor] : [], - id: idForLegend, + id: splitAccessor || accessors.join(','), xAccessor, - yAccessors, - data: sanitized.rows, + yAccessors: accessors, + data: table.rows, xScaleType, yScaleType, enableHistogramMode: isHistogram && (seriesType.includes('stacked') || !splitAccessor), timeZone, + name(d) { + if (accessors.length > 1) { + return d.seriesKeys + .map((key: string | number) => columnToLabelMap[key] || key) + .join(' - '); + } + return columnToLabelMap[d.seriesKeys[0]] ?? d.seriesKeys[0]; + }, }; return seriesType === 'line' ? ( - <LineSeries {...seriesProps} /> + <LineSeries key={index} {...seriesProps} /> ) : seriesType === 'bar' || seriesType === 'bar_stacked' || seriesType === 'bar_horizontal' || seriesType === 'bar_horizontal_stacked' ? ( - <BarSeries {...seriesProps} /> + <BarSeries key={index} {...seriesProps} /> ) : ( - <AreaSeries {...seriesProps} /> + <AreaSeries key={index} {...seriesProps} /> ); } )} </Chart> ); } - -/** - * Renames the columns to match the user-configured accessors in - * columnToLabelMap. If a splitAccessor is provided, formats the - * values in that column. - */ -function sanitizeRows({ - splitAccessor, - table, - formatFactory, - columnToLabelMap, -}: { - splitAccessor?: string; - table: KibanaDatatable; - formatFactory: FormatFactory; - columnToLabelMap: Record<string, string | undefined>; -}) { - const column = table.columns.find(c => c.id === splitAccessor); - const formatter = formatFactory(column && column.formatHint); - - return { - splitAccessor: column && column.id, - rows: table.rows.map(r => { - const newRow: typeof r = {}; - - if (column) { - newRow[column.id] = formatter.convert(r[column.id]); - } - - Object.keys(r).forEach(key => { - const newKey = columnToLabelMap[key] || key; - newRow[newKey] = r[key]; - }); - return newRow; - }), - }; -} From ec2972c224a7360decf948c1368e1f8f08ba2f58 Mon Sep 17 00:00:00 2001 From: Devon Thomson <devon.thomson@elastic.co> Date: Mon, 23 Mar 2020 17:03:01 -0400 Subject: [PATCH 040/179] Dashboard/add panel flow (#59918) Added an emphasize prop to the top nav menu item and used it for a new 'Create new' button which redirects to the 'new visualization' modal. Co-authored-by: Ryan Keairns <rkeairns@chef.io> --- .../np_ready/dashboard_app_controller.tsx | 5 +- .../np_ready/top_nav/get_top_nav_config.ts | 22 ++++++- .../dashboard/np_ready/top_nav/top_nav_ids.ts | 2 +- .../top_nav_menu_item.test.tsx.snap | 19 ++++++ .../public/top_nav_menu/_index.scss | 8 ++- .../public/top_nav_menu/top_nav_menu.tsx | 7 ++- .../public/top_nav_menu/top_nav_menu_data.tsx | 5 ++ .../top_nav_menu/top_nav_menu_item.test.tsx | 60 +++++++++++++++---- .../public/top_nav_menu/top_nav_menu_item.tsx | 26 ++++---- .../dashboard/create_and_add_embeddables.js | 17 +++++- .../services/dashboard/add_panel.js | 7 +++ 11 files changed, 148 insertions(+), 30 deletions(-) create mode 100644 src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu_item.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx index d1e4c9d2d2a0c..f1e1f20de1ce6 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx @@ -754,7 +754,7 @@ export class DashboardAppController { * When de-angularizing this code, please call the underlaying action function * directly and not via the top nav object. **/ - navActions[TopNavIds.ADD](); + navActions[TopNavIds.ADD_EXISTING](); }; $scope.enterEditMode = () => { dashboardStateManager.setFullScreenMode(false); @@ -847,7 +847,8 @@ export class DashboardAppController { showCloneModal(onClone, currentTitle); }; - navActions[TopNavIds.ADD] = () => { + + navActions[TopNavIds.ADD_EXISTING] = () => { if (dashboardContainer && !isErrorEmbeddable(dashboardContainer)) { openAddPanelFlyout({ embeddable: dashboardContainer, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/get_top_nav_config.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/get_top_nav_config.ts index 7188fab19d6f2..7a3cb4b7dad56 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/get_top_nav_config.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/get_top_nav_config.ts @@ -48,9 +48,10 @@ export function getTopNavConfig( ]; case ViewMode.EDIT: return [ + getCreateNewConfig(actions[TopNavIds.VISUALIZE]), getSaveConfig(actions[TopNavIds.SAVE]), getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), - getAddConfig(actions[TopNavIds.ADD]), + getAddConfig(actions[TopNavIds.ADD_EXISTING]), getOptionsConfig(actions[TopNavIds.OPTIONS]), getShareConfig(actions[TopNavIds.SHARE]), ]; @@ -161,6 +162,25 @@ function getAddConfig(action: NavAction) { }; } +/** + * @returns {kbnTopNavConfig} + */ +function getCreateNewConfig(action: NavAction) { + return { + emphasize: true, + iconType: 'plusInCircle', + id: 'addNew', + label: i18n.translate('kbn.dashboard.topNave.addNewButtonAriaLabel', { + defaultMessage: 'Create new', + }), + description: i18n.translate('kbn.dashboard.topNave.addNewConfigDescription', { + defaultMessage: 'Create a new panel on this dashboard', + }), + testId: 'dashboardAddNewPanelButton', + run: action, + }; +} + /** * @returns {kbnTopNavConfig} */ diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/top_nav_ids.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/top_nav_ids.ts index c67d6891c18e7..748bfaaab6141 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/top_nav_ids.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/top_nav_ids.ts @@ -18,7 +18,6 @@ */ export const TopNavIds = { - ADD: 'add', SHARE: 'share', OPTIONS: 'options', SAVE: 'save', @@ -27,4 +26,5 @@ export const TopNavIds = { CLONE: 'clone', FULL_SCREEN: 'fullScreenMode', VISUALIZE: 'visualize', + ADD_EXISTING: 'addExisting', }; diff --git a/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu_item.test.tsx.snap b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu_item.test.tsx.snap new file mode 100644 index 0000000000000..0d54d5d3e9c4a --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu_item.test.tsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TopNavMenu Should render emphasized item which should be clickable 1`] = ` +<EuiButton + fill={true} + iconSide="right" + iconType="beaker" + isDisabled={false} + onClick={[Function]} + size="s" + style={ + Object { + "fontSize": "smaller", + } + } +> + Test +</EuiButton> +`; diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index 4a0e6af3f7f70..5befe4789dd6c 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,7 +1,11 @@ .kbnTopNavMenu__wrapper { z-index: 5; - .kbnTopNavMenu { - padding: $euiSizeS 0px $euiSizeXS; + .kbnTopNavMenu { + padding: $euiSizeS 0; + + .kbnTopNavItemEmphasized { + padding: 0 $euiSizeS; + } } } diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index 80d1a53cd417f..14ad40f13e388 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -46,7 +46,11 @@ export function TopNavMenu(props: TopNavMenuProps) { if (!config) return; return config.map((menuItem: TopNavMenuData, i: number) => { return ( - <EuiFlexItem grow={false} key={`nav-menu-${i}`}> + <EuiFlexItem + grow={false} + key={`nav-menu-${i}`} + className={menuItem.emphasize ? 'kbnTopNavItemEmphasized' : ''} + > <TopNavMenuItem {...menuItem} /> </EuiFlexItem> ); @@ -66,6 +70,7 @@ export function TopNavMenu(props: TopNavMenuProps) { <EuiFlexGroup data-test-subj="top-nav" justifyContent="flexStart" + alignItems="center" gutterSize="none" className="kbnTopNavMenu" responsive={false} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx index f709985bcf453..2b7466ffd6ab3 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx @@ -17,6 +17,8 @@ * under the License. */ +import { ButtonIconSide } from '@elastic/eui'; + export type TopNavMenuAction = (anchorElement: EventTarget) => void; export interface TopNavMenuData { @@ -28,6 +30,9 @@ export interface TopNavMenuData { className?: string; disableButton?: boolean | (() => boolean); tooltip?: string | (() => string); + emphasize?: boolean; + iconType?: string; + iconSide?: ButtonIconSide; } export interface RegisteredTopNavMenuData extends TopNavMenuData { diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx index 4816ef3c95869..9ba58379c5ce1 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx @@ -23,6 +23,15 @@ import { TopNavMenuData } from './top_nav_menu_data'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; describe('TopNavMenu', () => { + const ensureMenuItemDisabled = (data: TopNavMenuData) => { + const component = shallowWithIntl(<TopNavMenuItem {...data} />); + expect(component.prop('isDisabled')).toEqual(true); + + const event = { currentTarget: { value: 'a' } }; + component.simulate('click', event); + expect(data.run).toHaveBeenCalledTimes(0); + }; + it('Should render and click an item', () => { const data: TopNavMenuData = { id: 'test', @@ -60,35 +69,62 @@ describe('TopNavMenu', () => { expect(data.run).toHaveBeenCalled(); }); - it('Should render disabled item and it shouldnt be clickable', () => { + it('Should render emphasized item which should be clickable', () => { const data: TopNavMenuData = { id: 'test', label: 'test', - disableButton: true, + iconType: 'beaker', + iconSide: 'right', + emphasize: true, run: jest.fn(), }; const component = shallowWithIntl(<TopNavMenuItem {...data} />); - expect(component.prop('isDisabled')).toEqual(true); - const event = { currentTarget: { value: 'a' } }; component.simulate('click', event); - expect(data.run).toHaveBeenCalledTimes(0); + expect(data.run).toHaveBeenCalledTimes(1); + expect(component).toMatchSnapshot(); + }); + + it('Should render disabled item and it shouldnt be clickable', () => { + ensureMenuItemDisabled({ + id: 'test', + label: 'test', + disableButton: true, + run: jest.fn(), + }); }); it('Should render item with disable function and it shouldnt be clickable', () => { - const data: TopNavMenuData = { + ensureMenuItemDisabled({ id: 'test', label: 'test', disableButton: () => true, run: jest.fn(), - }; + }); + }); - const component = shallowWithIntl(<TopNavMenuItem {...data} />); - expect(component.prop('isDisabled')).toEqual(true); + it('Should render disabled emphasized item which shouldnt be clickable', () => { + ensureMenuItemDisabled({ + id: 'test', + label: 'test', + iconType: 'beaker', + iconSide: 'right', + emphasize: true, + disableButton: true, + run: jest.fn(), + }); + }); - const event = { currentTarget: { value: 'a' } }; - component.simulate('click', event); - expect(data.run).toHaveBeenCalledTimes(0); + it('Should render emphasized item with disable function and it shouldnt be clickable', () => { + ensureMenuItemDisabled({ + id: 'test', + label: 'test', + iconType: 'beaker', + iconSide: 'right', + emphasize: true, + disableButton: () => true, + run: jest.fn(), + }); }); }); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx index 4d3b72bae6411..92e267f17d08e 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -21,6 +21,7 @@ import { capitalize, isFunction } from 'lodash'; import React, { MouseEvent } from 'react'; import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; import { TopNavMenuData } from './top_nav_menu_data'; export function TopNavMenuItem(props: TopNavMenuData) { @@ -39,14 +40,20 @@ export function TopNavMenuItem(props: TopNavMenuData) { props.run(e.currentTarget); } - const btn = ( - <EuiButtonEmpty - size="xs" - isDisabled={isDisabled()} - onClick={handleClick} - data-test-subj={props.testId} - className={props.className} - > + const commonButtonProps = { + isDisabled: isDisabled(), + onClick: handleClick, + iconType: props.iconType, + iconSide: props.iconSide, + 'data-test-subj': props.testId, + }; + + const btn = props.emphasize ? ( + <EuiButton {...commonButtonProps} size="s" fill style={{ fontSize: 'smaller' }}> + {capitalize(props.label || props.id!)} + </EuiButton> + ) : ( + <EuiButtonEmpty {...commonButtonProps} size="xs"> {capitalize(props.label || props.id!)} </EuiButtonEmpty> ); @@ -54,9 +61,8 @@ export function TopNavMenuItem(props: TopNavMenuData) { const tooltip = getTooltip(); if (tooltip) { return <EuiToolTip content={tooltip}>{btn}</EuiToolTip>; - } else { - return btn; } + return btn; } TopNavMenuItem.defaultProps = { diff --git a/test/functional/apps/dashboard/create_and_add_embeddables.js b/test/functional/apps/dashboard/create_and_add_embeddables.js index 5ebb9fdf6330f..3ce8e353e61fc 100644 --- a/test/functional/apps/dashboard/create_and_add_embeddables.js +++ b/test/functional/apps/dashboard/create_and_add_embeddables.js @@ -41,9 +41,24 @@ export default function({ getService, getPageObjects }) { }); describe('add new visualization link', () => { - it('adds a new visualization', async () => { + it('adds new visualiztion via the top nav link', async () => { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); await PageObjects.dashboard.switchToEditMode(); + await dashboardAddPanel.clickCreateNewLink(); + await PageObjects.visualize.clickAreaChart(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'visualization from top nav add new panel' + ); + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + it('adds a new visualization', async () => { + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); await dashboardAddPanel.ensureAddPanelIsShowing(); await dashboardAddPanel.clickAddNewEmbeddableLink('visualization'); await PageObjects.visualize.clickAreaChart(); diff --git a/test/functional/services/dashboard/add_panel.js b/test/functional/services/dashboard/add_panel.js index 91e7c15c4f1d9..6259203982161 100644 --- a/test/functional/services/dashboard/add_panel.js +++ b/test/functional/services/dashboard/add_panel.js @@ -32,6 +32,13 @@ export function DashboardAddPanelProvider({ getService, getPageObjects }) { await PageObjects.common.sleep(500); } + async clickCreateNewLink() { + log.debug('DashboardAddPanel.clickAddNewPanelButton'); + await testSubjects.click('dashboardAddNewPanelButton'); + // Give some time for the animation to complete + await PageObjects.common.sleep(500); + } + async clickAddNewEmbeddableLink(type) { await testSubjects.click('createNew'); await testSubjects.click(`createNew-${type}`); From f7a30498439c7cce2a856cd480ab8afed7eafdac Mon Sep 17 00:00:00 2001 From: Wylie Conlon <william.conlon@elastic.co> Date: Mon, 23 Mar 2020 17:11:09 -0400 Subject: [PATCH 041/179] [Lens] Improve suggestions when dragging field for the second time (#60687) * [Lens] Improve suggestions when dragging into an existing visualization * Include 0 metrics case Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../indexpattern_suggestions.test.tsx | 90 ++++++++++++++++--- .../indexpattern_suggestions.ts | 62 +++++++++---- 2 files changed, 120 insertions(+), 32 deletions(-) diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index 4e48d0c0987b5..e36622f876acd 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -780,7 +780,7 @@ describe('IndexPattern Data Source suggestions', () => { expect(suggestions[0].table.columns[0].operation.isBucketed).toBeFalsy(); }); - it('prepends a terms column on string field', () => { + it('appends a terms column on string field', () => { const initialState = stateWithNonEmptyTables(); const suggestions = getDatasourceSuggestionsForField(initialState, '1', { name: 'dest', @@ -795,7 +795,7 @@ describe('IndexPattern Data Source suggestions', () => { layers: { previousLayer: initialState.layers.previousLayer, currentLayer: expect.objectContaining({ - columnOrder: ['id1', 'cola', 'colb'], + columnOrder: ['cola', 'id1', 'colb'], columns: { ...initialState.layers.currentLayer.columns, id1: expect.objectContaining({ @@ -810,7 +810,7 @@ describe('IndexPattern Data Source suggestions', () => { ); }); - it('appends a metric column on a number field', () => { + it('replaces a metric column on a number field if only one other metric is already set', () => { const initialState = stateWithNonEmptyTables(); const suggestions = getDatasourceSuggestionsForField(initialState, '1', { name: 'memory', @@ -819,15 +819,57 @@ describe('IndexPattern Data Source suggestions', () => { searchable: true, }); + expect(suggestions).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ + layers: expect.objectContaining({ + currentLayer: expect.objectContaining({ + columnOrder: ['cola', 'id1'], + columns: { + cola: initialState.layers.currentLayer.columns.cola, + id1: expect.objectContaining({ + operationType: 'avg', + sourceField: 'memory', + }), + }, + }), + }), + }), + }) + ); + }); + + it('adds a metric column on a number field if no other metrics set', () => { + const initialState = stateWithNonEmptyTables(); + const modifiedState: IndexPatternPrivateState = { + ...initialState, + layers: { + ...initialState.layers, + currentLayer: { + ...initialState.layers.currentLayer, + columns: { + cola: initialState.layers.currentLayer.columns.cola, + }, + columnOrder: ['cola'], + }, + }, + }; + const suggestions = getDatasourceSuggestionsForField(modifiedState, '1', { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }); + expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { - previousLayer: initialState.layers.previousLayer, + previousLayer: modifiedState.layers.previousLayer, currentLayer: expect.objectContaining({ - columnOrder: ['cola', 'colb', 'id1'], + columnOrder: ['cola', 'id1'], columns: { - ...initialState.layers.currentLayer.columns, + ...modifiedState.layers.currentLayer.columns, id1: expect.objectContaining({ operationType: 'avg', sourceField: 'memory', @@ -840,10 +882,30 @@ describe('IndexPattern Data Source suggestions', () => { ); }); - it('appends a metric column with a different operation on a number field if field is already in use', () => { + it('adds a metric column on a number field if 2 or more other metric', () => { const initialState = stateWithNonEmptyTables(); - const suggestions = getDatasourceSuggestionsForField(initialState, '1', { - name: 'bytes', + const modifiedState: IndexPatternPrivateState = { + ...initialState, + layers: { + ...initialState.layers, + currentLayer: { + ...initialState.layers.currentLayer, + columns: { + ...initialState.layers.currentLayer.columns, + colc: { + dataType: 'number', + isBucketed: false, + sourceField: 'dest', + label: 'Unique count of dest', + operationType: 'cardinality', + }, + }, + columnOrder: ['cola', 'colb', 'colc'], + }, + }, + }; + const suggestions = getDatasourceSuggestionsForField(modifiedState, '1', { + name: 'memory', type: 'number', aggregatable: true, searchable: true, @@ -853,14 +915,14 @@ describe('IndexPattern Data Source suggestions', () => { expect.objectContaining({ state: expect.objectContaining({ layers: { - previousLayer: initialState.layers.previousLayer, + previousLayer: modifiedState.layers.previousLayer, currentLayer: expect.objectContaining({ - columnOrder: ['cola', 'colb', 'id1'], + columnOrder: ['cola', 'colb', 'colc', 'id1'], columns: { - ...initialState.layers.currentLayer.columns, + ...modifiedState.layers.currentLayer.columns, id1: expect.objectContaining({ - operationType: 'sum', - sourceField: 'bytes', + operationType: 'avg', + sourceField: 'memory', }), }, }), diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts index 35e99fc4fe98d..96127caa67bb4 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -197,16 +197,29 @@ function addFieldAsMetricOperation( field, }); const newColumnId = generateId(); - const updatedColumns = { - ...layer.columns, - [newColumnId]: newColumn, - }; - const updatedColumnOrder = [...layer.columnOrder, newColumnId]; + + const [, metrics] = separateBucketColumns(layer); + + // Add metrics if there are 0 or > 1 metric + if (metrics.length !== 1) { + return { + indexPatternId: indexPattern.id, + columns: { + ...layer.columns, + [newColumnId]: newColumn, + }, + columnOrder: [...layer.columnOrder, newColumnId], + }; + } + + // If only one metric, replace instead of add + const newColumns = { ...layer.columns, [newColumnId]: newColumn }; + delete newColumns[metrics[0]]; return { indexPatternId: indexPattern.id, - columns: updatedColumns, - columnOrder: updatedColumnOrder, + columns: newColumns, + columnOrder: [...layer.columnOrder.filter(c => c !== metrics[0]), newColumnId], }; } @@ -231,21 +244,34 @@ function addFieldAsBucketOperation( ...layer.columns, [newColumnId]: newColumn, }; + + const oldDateHistogramIndex = layer.columnOrder.findIndex( + columnId => layer.columns[columnId].operationType === 'date_histogram' + ); + const oldDateHistogramId = + oldDateHistogramIndex > -1 ? layer.columnOrder[oldDateHistogramIndex] : null; + let updatedColumnOrder: string[] = []; - if (applicableBucketOperation === 'terms') { - updatedColumnOrder = [newColumnId, ...buckets, ...metrics]; - } else { - const oldDateHistogramColumn = layer.columnOrder.find( - columnId => layer.columns[columnId].operationType === 'date_histogram' - ); - if (oldDateHistogramColumn) { - delete updatedColumns[oldDateHistogramColumn]; + if (oldDateHistogramId) { + if (applicableBucketOperation === 'terms') { + // Insert the new terms bucket above the first date histogram + updatedColumnOrder = [ + ...buckets.slice(0, oldDateHistogramIndex), + newColumnId, + ...buckets.slice(oldDateHistogramIndex, buckets.length), + ...metrics, + ]; + } else if (applicableBucketOperation === 'date_histogram') { + // Replace date histogram with new date histogram + delete updatedColumns[oldDateHistogramId]; updatedColumnOrder = layer.columnOrder.map(columnId => - columnId !== oldDateHistogramColumn ? columnId : newColumnId + columnId !== oldDateHistogramId ? columnId : newColumnId ); - } else { - updatedColumnOrder = [...buckets, newColumnId, ...metrics]; } + } else { + // Insert the new bucket after existing buckets. Users will see the same data + // they already had, with an extra level of detail. + updatedColumnOrder = [...buckets, newColumnId, ...metrics]; } return { indexPatternId: indexPattern.id, From d5c13c043b9346233c91401091fd19fd607f7193 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar <dario.gieselaar@elastic.co> Date: Mon, 23 Mar 2020 22:13:32 +0100 Subject: [PATCH 042/179] [APM] use span.destination.service.resource (#60908) * [APM] use span.destination.service.resource Closes #60405. * update snapshots Co-authored-by: Nathan L Smith <smith@nlsmith.com> --- .../app/ServiceMap/cytoscapeOptions.ts | 8 +- .../elasticsearch_fieldnames.test.ts.snap | 6 + .../apm/common/elasticsearch_fieldnames.ts | 2 + x-pack/plugins/apm/common/service_map.ts | 6 +- .../dedupe_connections/index.test.ts | 143 ++++++++++++++++++ .../index.ts} | 104 ++++++++++--- .../get_service_map_from_trace_ids.ts | 16 +- .../lib/service_map/get_trace_sample_ids.ts | 25 +-- 8 files changed, 250 insertions(+), 60 deletions(-) create mode 100644 x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.test.ts rename x-pack/plugins/apm/server/lib/service_map/{dedupe_connections.ts => dedupe_connections/index.ts} (54%) diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index e19cb8ae4b646..e92a4fe797855 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -7,8 +7,8 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import cytoscape from 'cytoscape'; import { CSSProperties } from 'react'; import { - DESTINATION_ADDRESS, - SERVICE_NAME + SERVICE_NAME, + SPAN_DESTINATION_SERVICE_RESOURCE } from '../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; import { defaultIcon, iconForNode } from './icons'; @@ -59,7 +59,9 @@ const style: cytoscape.Stylesheet[] = [ 'ghost-opacity': 0.15, height: nodeHeight, label: (el: cytoscape.NodeSingular) => - isService(el) ? el.data(SERVICE_NAME) : el.data(DESTINATION_ADDRESS), + isService(el) + ? el.data(SERVICE_NAME) + : el.data(SPAN_DESTINATION_SERVICE_RESOURCE), 'min-zoomed-font-size': theme.euiSizeL, 'overlay-opacity': 0, shape: (el: cytoscape.NodeSingular) => diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 9a557532aae93..897d4e979fce3 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -76,6 +76,8 @@ exports[`Error SERVICE_VERSION 1`] = `undefined`; exports[`Error SPAN_ACTION 1`] = `undefined`; +exports[`Error SPAN_DESTINATION_SERVICE_RESOURCE 1`] = `undefined`; + exports[`Error SPAN_DURATION 1`] = `undefined`; exports[`Error SPAN_ID 1`] = `undefined`; @@ -188,6 +190,8 @@ exports[`Span SERVICE_VERSION 1`] = `undefined`; exports[`Span SPAN_ACTION 1`] = `"my action"`; +exports[`Span SPAN_DESTINATION_SERVICE_RESOURCE 1`] = `undefined`; + exports[`Span SPAN_DURATION 1`] = `1337`; exports[`Span SPAN_ID 1`] = `"span id"`; @@ -300,6 +304,8 @@ exports[`Transaction SERVICE_VERSION 1`] = `undefined`; exports[`Transaction SPAN_ACTION 1`] = `undefined`; +exports[`Transaction SPAN_DESTINATION_SERVICE_RESOURCE 1`] = `undefined`; + exports[`Transaction SPAN_DURATION 1`] = `undefined`; exports[`Transaction SPAN_ID 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index 8f1b306a34eb0..822201baddd88 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -39,6 +39,8 @@ export const SPAN_SELF_TIME_SUM = 'span.self_time.sum.us'; export const SPAN_ACTION = 'span.action'; export const SPAN_NAME = 'span.name'; export const SPAN_ID = 'span.id'; +export const SPAN_DESTINATION_SERVICE_RESOURCE = + 'span.destination.service.resource'; // Parent ID for a transaction or span export const PARENT_ID = 'parent.id'; diff --git a/x-pack/plugins/apm/common/service_map.ts b/x-pack/plugins/apm/common/service_map.ts index 8c749cd00bd32..4a8608199c037 100644 --- a/x-pack/plugins/apm/common/service_map.ts +++ b/x-pack/plugins/apm/common/service_map.ts @@ -8,12 +8,12 @@ import { i18n } from '@kbn/i18n'; import { ILicense } from '../../licensing/public'; import { AGENT_NAME, - DESTINATION_ADDRESS, SERVICE_ENVIRONMENT, SERVICE_FRAMEWORK_NAME, SERVICE_NAME, SPAN_SUBTYPE, - SPAN_TYPE + SPAN_TYPE, + SPAN_DESTINATION_SERVICE_RESOURCE } from './elasticsearch_fieldnames'; export interface ServiceConnectionNode { @@ -23,7 +23,7 @@ export interface ServiceConnectionNode { [AGENT_NAME]: string; } export interface ExternalConnectionNode { - [DESTINATION_ADDRESS]: string; + [SPAN_DESTINATION_SERVICE_RESOURCE]: string; [SPAN_TYPE]: string; [SPAN_SUBTYPE]: string; } diff --git a/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.test.ts b/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.test.ts new file mode 100644 index 0000000000000..01d6a2e2e81bc --- /dev/null +++ b/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.test.ts @@ -0,0 +1,143 @@ +/* + * 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 { ServiceMapResponse } from './'; +import { + SPAN_DESTINATION_SERVICE_RESOURCE, + SERVICE_NAME, + SERVICE_ENVIRONMENT, + SERVICE_FRAMEWORK_NAME, + AGENT_NAME, + SPAN_TYPE, + SPAN_SUBTYPE +} from '../../../../common/elasticsearch_fieldnames'; +import { dedupeConnections } from './'; + +const nodejsService = { + [SERVICE_NAME]: 'opbeans-node', + [SERVICE_ENVIRONMENT]: 'production', + [SERVICE_FRAMEWORK_NAME]: null, + [AGENT_NAME]: 'nodejs' +}; + +const nodejsExternal = { + [SPAN_DESTINATION_SERVICE_RESOURCE]: 'opbeans-node', + [SPAN_TYPE]: 'external', + [SPAN_SUBTYPE]: 'aa' +}; + +const javaService = { + [SERVICE_NAME]: 'opbeans-java', + [SERVICE_ENVIRONMENT]: 'production', + [SERVICE_FRAMEWORK_NAME]: null, + [AGENT_NAME]: 'java' +}; + +describe('dedupeConnections', () => { + it('maps external destinations to internal services', () => { + const response: ServiceMapResponse = { + services: [nodejsService, javaService], + discoveredServices: [ + { + from: nodejsExternal, + to: nodejsService + } + ], + connections: [ + { + source: javaService, + destination: nodejsExternal + } + ] + }; + + const { elements } = dedupeConnections(response); + + const connection = elements.find( + element => 'source' in element.data && 'target' in element.data + ); + + // @ts-ignore + expect(connection?.data.target).toBe('opbeans-node'); + + expect( + elements.find(element => element.data.id === '>opbeans-node') + ).toBeUndefined(); + }); + + it('collapses external destinations based on span.destination.resource.name', () => { + const response: ServiceMapResponse = { + services: [nodejsService, javaService], + discoveredServices: [ + { + from: nodejsExternal, + to: nodejsService + } + ], + connections: [ + { + source: javaService, + destination: nodejsExternal + }, + { + source: javaService, + destination: { + ...nodejsExternal, + [SPAN_TYPE]: 'foo' + } + } + ] + }; + + const { elements } = dedupeConnections(response); + + const connections = elements.filter(element => 'source' in element.data); + + expect(connections.length).toBe(1); + + const nodes = elements.filter(element => !('source' in element.data)); + + expect(nodes.length).toBe(2); + }); + + it('picks the first span.type/subtype in an alphabetically sorted list', () => { + const response: ServiceMapResponse = { + services: [javaService], + discoveredServices: [], + connections: [ + { + source: javaService, + destination: nodejsExternal + }, + { + source: javaService, + destination: { + ...nodejsExternal, + [SPAN_TYPE]: 'foo' + } + }, + { + source: javaService, + destination: { + ...nodejsExternal, + [SPAN_SUBTYPE]: 'bb' + } + } + ] + }; + + const { elements } = dedupeConnections(response); + + const nodes = elements.filter(element => !('source' in element.data)); + + const nodejsNode = nodes.find(node => node.data.id === '>opbeans-node'); + + // @ts-ignore + expect(nodejsNode?.data[SPAN_TYPE]).toBe('external'); + // @ts-ignore + expect(nodejsNode?.data[SPAN_SUBTYPE]).toBe('aa'); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/service_map/dedupe_connections.ts b/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.ts similarity index 54% rename from x-pack/plugins/apm/server/lib/service_map/dedupe_connections.ts rename to x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.ts index 485958cc17afd..d256f657bb778 100644 --- a/x-pack/plugins/apm/server/lib/service_map/dedupe_connections.ts +++ b/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.ts @@ -6,23 +6,25 @@ import { isEqual, sortBy } from 'lodash'; import { ValuesType } from 'utility-types'; import { - DESTINATION_ADDRESS, - SERVICE_NAME -} from '../../../common/elasticsearch_fieldnames'; + SERVICE_NAME, + SPAN_DESTINATION_SERVICE_RESOURCE, + SPAN_TYPE, + SPAN_SUBTYPE +} from '../../../../common/elasticsearch_fieldnames'; import { Connection, ConnectionNode, - ExternalConnectionNode, - ServiceConnectionNode -} from '../../../common/service_map'; -import { ConnectionsResponse, ServicesResponse } from './get_service_map'; + ServiceConnectionNode, + ExternalConnectionNode +} from '../../../../common/service_map'; +import { ConnectionsResponse, ServicesResponse } from '../get_service_map'; function getConnectionNodeId(node: ConnectionNode): string { - if (DESTINATION_ADDRESS in node) { + if ('span.destination.service.resource' in node) { // use a prefix to distinguish exernal destination ids from services - return `>${(node as ExternalConnectionNode)[DESTINATION_ADDRESS]}`; + return `>${node[SPAN_DESTINATION_SERVICE_RESOURCE]}`; } - return (node as ServiceConnectionNode)[SERVICE_NAME]; + return node[SERVICE_NAME]; } function getConnectionId(connection: Connection) { @@ -31,32 +33,86 @@ function getConnectionId(connection: Connection) { )}`; } -type ServiceMapResponse = ConnectionsResponse & { services: ServicesResponse }; +export type ServiceMapResponse = ConnectionsResponse & { + services: ServicesResponse; +}; export function dedupeConnections(response: ServiceMapResponse) { const { discoveredServices, services, connections } = response; - const serviceNodes = services.map(service => ({ - ...service, - id: service[SERVICE_NAME] - })); + const allNodes = connections + .flatMap(connection => [connection.source, connection.destination]) + .map(node => ({ ...node, id: getConnectionNodeId(node) })) + .concat( + services.map(service => ({ + ...service, + id: service[SERVICE_NAME] + })) + ); - // maps destination.address to service.name if possible - function getConnectionNode(node: ConnectionNode) { - let mappedNode: ConnectionNode | undefined; + const serviceNodes = allNodes.filter(node => SERVICE_NAME in node) as Array< + ServiceConnectionNode & { + id: string; + } + >; - if (DESTINATION_ADDRESS in node) { - mappedNode = discoveredServices.find(map => isEqual(map.from, node))?.to; + const externalNodes = allNodes.filter( + node => SPAN_DESTINATION_SERVICE_RESOURCE in node + ) as Array< + ExternalConnectionNode & { + id: string; + } + >; + + // 1. maps external nodes to internal services + // 2. collapses external nodes into one node based on span.destination.service.resource + // 3. picks the first available span.type/span.subtype in an alphabetically sorted list + const nodeMap = allNodes.reduce((map, node) => { + if (map[node.id]) { + return map; } - if (!mappedNode) { - mappedNode = node; + const service = + discoveredServices.find(({ from }) => { + if ('span.destination.service.resource' in node) { + return ( + node[SPAN_DESTINATION_SERVICE_RESOURCE] === + from[SPAN_DESTINATION_SERVICE_RESOURCE] + ); + } + return false; + })?.to ?? serviceNodes.find(serviceNode => serviceNode.id === node.id); + + if (service) { + return { + ...map, + [node.id]: { + id: service[SERVICE_NAME], + ...service + } + }; } + const allMatchedExternalNodes = externalNodes.filter(n => n.id === node.id); + + const firstMatchedNode = allMatchedExternalNodes[0]; + return { - ...mappedNode, - id: getConnectionNodeId(mappedNode) + ...map, + [node.id]: { + ...firstMatchedNode, + label: firstMatchedNode[SPAN_DESTINATION_SERVICE_RESOURCE], + [SPAN_TYPE]: allMatchedExternalNodes.map(n => n[SPAN_TYPE]).sort()[0], + [SPAN_SUBTYPE]: allMatchedExternalNodes + .map(n => n[SPAN_SUBTYPE]) + .sort()[0] + } }; + }, {} as Record<string, ConnectionNode & { id: string }>); + + // maps destination.address to service.name if possible + function getConnectionNode(node: ConnectionNode) { + return nodeMap[getConnectionNodeId(node)]; } // build connections with mapped nodes diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts index d6d9e9b875408..eaf3d63521a98 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts @@ -65,7 +65,7 @@ export async function getServiceMapFromTraceIds({ 'parent.id', 'service.name', 'service.environment', - 'destination.address', + 'span.destination.service.resource', 'trace.id', 'processor.event', 'span.type', @@ -103,7 +103,7 @@ export async function getServiceMapFromTraceIds({ source: ` def getDestination ( def event ) { def destination = new HashMap(); - destination['destination.address'] = event['destination.address']; + destination['span.destination.service.resource'] = event['span.destination.service.resource']; destination['span.type'] = event['span.type']; destination['span.subtype'] = event['span.subtype']; return destination; @@ -138,13 +138,11 @@ export async function getServiceMapFromTraceIds({ /* flag parent path for removal, as it has children */ context.locationsToRemove.add(parent.path); - /* if the parent has 'destination.address' set, and the service is different, + /* if the parent has 'span.destination.service.resource' set, and the service is different, we've discovered a service */ - if (parent['destination.address'] != null - && parent['destination.address'] != "" - && (parent['span.type'] == 'external' - || parent['span.type'] == 'messaging') + if (parent['span.destination.service.resource'] != null + && parent['span.destination.service.resource'] != "" && (parent['service.name'] != event['service.name'] || parent['service.environment'] != event['service.environment'] ) @@ -165,8 +163,8 @@ export async function getServiceMapFromTraceIds({ } /* if there is an outgoing span, create a new path */ - if (event['destination.address'] != null - && event['destination.address'] != '') { + if (event['span.destination.service.resource'] != null + && event['span.destination.service.resource'] != '') { def outgoingLocation = getDestination(event); def outgoingPath = new ArrayList(basePath); outgoingPath.add(outgoingLocation); diff --git a/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts b/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts index f4e12df5d6a66..9cb1a61e1d76f 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts @@ -16,9 +16,7 @@ import { SERVICE_NAME, SERVICE_ENVIRONMENT, TRACE_ID, - DESTINATION_ADDRESS, - SPAN_TYPE, - SPAN_SUBTYPE + SPAN_DESTINATION_SERVICE_RESOURCE } from '../../../common/elasticsearch_fieldnames'; const MAX_TRACES_TO_INSPECT = 1000; @@ -46,7 +44,7 @@ export async function getTraceSampleIds({ }, { exists: { - field: DESTINATION_ADDRESS + field: SPAN_DESTINATION_SERVICE_RESOURCE } }, rangeQuery @@ -82,9 +80,9 @@ export async function getTraceSampleIds({ composite: { sources: [ { - [DESTINATION_ADDRESS]: { + [SPAN_DESTINATION_SERVICE_RESOURCE]: { terms: { - field: DESTINATION_ADDRESS + field: SPAN_DESTINATION_SERVICE_RESOURCE } } }, @@ -102,21 +100,6 @@ export async function getTraceSampleIds({ missing_bucket: true } } - }, - { - [SPAN_TYPE]: { - terms: { - field: SPAN_TYPE - } - } - }, - { - [SPAN_SUBTYPE]: { - terms: { - field: SPAN_SUBTYPE, - missing_bucket: true - } - } } ], size: fingerprintBucketSize From a0a85dbb90a6aca8dc17081ed9168219ab90de36 Mon Sep 17 00:00:00 2001 From: Nathan L Smith <nathan.smith@elastic.co> Date: Mon, 23 Mar 2020 16:13:56 -0500 Subject: [PATCH 043/179] Simplify service map layout (#60949) Clean up the cytoscape component and event handlers to simplify the layout logic. Make all centering animations animated. Add logging of cytoscape events when we're in debug mode. Add Elasticsearch icon. --- .../app/ServiceMap/Cytoscape.stories.tsx | 8 ++ .../components/app/ServiceMap/Cytoscape.tsx | 94 +++++++++---------- .../app/ServiceMap/Popover/index.tsx | 6 +- .../app/ServiceMap/cytoscapeOptions.ts | 5 - .../public/components/app/ServiceMap/icons.ts | 9 +- .../app/ServiceMap/icons/elasticsearch.svg | 1 + 6 files changed, 69 insertions(+), 54 deletions(-) create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/elasticsearch.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx index 155695f7596dd..7a066b520cc3b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx @@ -77,6 +77,14 @@ storiesOf('app/ServiceMap/Cytoscape', module) { data: { id: 'default' } }, { data: { id: 'cache', label: 'cache', 'span.type': 'cache' } }, { data: { id: 'database', label: 'database', 'span.type': 'db' } }, + { + data: { + id: 'elasticsearch', + label: 'elasticsearch', + 'span.type': 'db', + 'span.subtype': 'elasticsearch' + } + }, { data: { id: 'external', label: 'external', 'span.type': 'external' } }, diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index e0a188b4915a2..a4cd6f4ed09a9 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -9,7 +9,6 @@ import React, { createContext, CSSProperties, ReactNode, - useCallback, useEffect, useRef, useState @@ -109,23 +108,26 @@ export function Cytoscape({ serviceName, style }: CytoscapeProps) { - const initialElements = elements.map(element => ({ - ...element, - // prevents flash of unstyled elements - classes: [element.classes, 'invisible'].join(' ').trim() - })); - const [ref, cy] = useCytoscape({ ...cytoscapeOptions, - elements: initialElements + elements }); // Add the height to the div style. The height is a separate prop because it // is required and can trigger rendering when changed. const divStyle = { ...style, height }; - const resetConnectedEdgeStyle = useCallback( - (node?: cytoscape.NodeSingular) => { + // Trigger a custom "data" event when data changes + useEffect(() => { + if (cy && elements.length > 0) { + cy.add(elements); + cy.trigger('data'); + } + }, [cy, elements]); + + // Set up cytoscape event handlers + useEffect(() => { + const resetConnectedEdgeStyle = (node?: cytoscape.NodeSingular) => { if (cy) { cy.edges().removeClass('highlight'); @@ -133,12 +135,9 @@ export function Cytoscape({ node.connectedEdges().addClass('highlight'); } } - }, - [cy] - ); + }; - const dataHandler = useCallback<cytoscape.EventHandler>( - event => { + const dataHandler: cytoscape.EventHandler = event => { if (cy) { if (serviceName) { resetConnectedEdgeStyle(cy.getElementById(serviceName)); @@ -150,36 +149,25 @@ export function Cytoscape({ } else { resetConnectedEdgeStyle(); } - if (event.cy.elements().length > 0) { - const selectedRoots = selectRoots(event.cy); - const layout = cy.layout( - getLayoutOptions(selectedRoots, height, width) - ); - layout.one('layoutstop', () => { - if (serviceName) { - const focusedNode = cy.getElementById(serviceName); - cy.center(focusedNode); - } - // show elements after layout is applied - cy.elements().removeClass('invisible'); - }); - layout.run(); - } - } - }, - [cy, resetConnectedEdgeStyle, serviceName, height, width] - ); - // Trigger a custom "data" event when data changes - useEffect(() => { - if (cy) { - cy.add(elements); - cy.trigger('data'); - } - }, [cy, elements]); + const selectedRoots = selectRoots(event.cy); + const layout = cy.layout( + getLayoutOptions(selectedRoots, height, width) + ); - // Set up cytoscape event handlers - useEffect(() => { + layout.run(); + } + }; + const layoutstopHandler: cytoscape.EventHandler = event => { + event.cy.animate({ + ...animationOptions, + center: { + eles: serviceName + ? event.cy.getElementById(serviceName) + : event.cy.collection() + } + }); + }; const mouseoverHandler: cytoscape.EventHandler = event => { event.target.addClass('hover'); event.target.connectedEdges().addClass('nodeHover'); @@ -194,10 +182,18 @@ export function Cytoscape({ const unselectHandler: cytoscape.EventHandler = event => { resetConnectedEdgeStyle(); }; + const debugHandler: cytoscape.EventHandler = event => { + const debugEnabled = sessionStorage.getItem('apm_debug') === 'true'; + if (debugEnabled) { + // eslint-disable-next-line no-console + console.debug('cytoscape:', event); + } + }; if (cy) { + cy.on('data layoutstop select unselect', debugHandler); cy.on('data', dataHandler); - cy.ready(dataHandler); + cy.on('layoutstop', layoutstopHandler); cy.on('mouseover', 'edge, node', mouseoverHandler); cy.on('mouseout', 'edge, node', mouseoutHandler); cy.on('select', 'node', selectHandler); @@ -207,15 +203,19 @@ export function Cytoscape({ return () => { if (cy) { cy.removeListener( - 'data', + 'data layoutstop select unselect', undefined, - dataHandler as cytoscape.EventHandler + debugHandler ); + cy.removeListener('data', undefined, dataHandler); + cy.removeListener('layoutstop', undefined, layoutstopHandler); cy.removeListener('mouseover', 'edge, node', mouseoverHandler); cy.removeListener('mouseout', 'edge, node', mouseoutHandler); + cy.removeListener('select', 'node', selectHandler); + cy.removeListener('unselect', 'node', unselectHandler); } }; - }, [cy, dataHandler, resetConnectedEdgeStyle, serviceName]); + }, [cy, height, serviceName, width]); return ( <CytoscapeContext.Provider value={cy}> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx index 13aa53a8cf4b2..102b135f3cd1f 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx @@ -17,6 +17,7 @@ import React, { import { SERVICE_NAME } from '../../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; import { CytoscapeContext } from '../Cytoscape'; import { Contents } from './Contents'; +import { animationOptions } from '../cytoscapeOptions'; interface PopoverProps { focusedServiceName?: string; @@ -88,7 +89,10 @@ export function Popover({ focusedServiceName }: PopoverProps) { const centerSelectedNode = useCallback(() => { if (cy) { - cy.center(cy.getElementById(selectedNodeServiceName)); + cy.animate({ + ...animationOptions, + center: { eles: cy.getElementById(selectedNodeServiceName) } + }); } }, [cy, selectedNodeServiceName]); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index e92a4fe797855..413458f336e6f 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -115,11 +115,6 @@ const style: cytoscape.Stylesheet[] = [ selector: 'edge[isInverseEdge]', style: { visibility: 'hidden' } }, - // @ts-ignore - { - selector: '.invisible', - style: { visibility: 'hidden' } - }, { selector: 'edge.nodeHover', style: { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts index 5102dfc02f757..4925ffba310b5 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts @@ -8,12 +8,14 @@ import cytoscape from 'cytoscape'; import { AGENT_NAME, SERVICE_NAME, - SPAN_TYPE + SPAN_TYPE, + SPAN_SUBTYPE } from '../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; import databaseIcon from './icons/database.svg'; import defaultIconImport from './icons/default.svg'; import documentsIcon from './icons/documents.svg'; import dotNetIcon from './icons/dot-net.svg'; +import elasticsearchIcon from './icons/elasticsearch.svg'; import globeIcon from './icons/globe.svg'; import goIcon from './icons/go.svg'; import javaIcon from './icons/java.svg'; @@ -63,6 +65,11 @@ export function iconForNode(node: cytoscape.NodeSingular) { return serviceIcons[node.data(AGENT_NAME) as string]; } else if (isIE11) { return defaultIcon; + } else if ( + node.data(SPAN_TYPE) === 'db' && + node.data(SPAN_SUBTYPE) === 'elasticsearch' + ) { + return elasticsearchIcon; } else if (icons[type]) { return icons[type]; } else { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/elasticsearch.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/elasticsearch.svg new file mode 100644 index 0000000000000..4f9fda36ba06a --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/elasticsearch.svg @@ -0,0 +1 @@ +<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" class="euiIcon euiIcon--xLarge euiIcon-isLoaded" focusable="false" role="img" aria-hidden="true"><g fill="black" fill-rule="evenodd"><path class="euiIcon__fillNegative" d="M2 16c0 1.384.194 2.72.524 4H22a4 4 0 000-8H2.524A15.984 15.984 0 002 16"></path><path fill="#FEC514" d="M28.924 7.662A15.381 15.381 0 0030.48 6C27.547 2.346 23.05 0 18 0 11.679 0 6.239 3.678 3.644 9H25.51a5.039 5.039 0 003.413-1.338"></path><path fill="#00BFB3" d="M25.51 23H3.645C6.24 28.323 11.679 32 18 32c5.05 0 9.547-2.346 12.48-6a15.381 15.381 0 00-1.556-1.662A5.034 5.034 0 0025.51 23"></path></g></svg> From dd93a14fefebd70a64165adfae3dcc3512f8e623 Mon Sep 17 00:00:00 2001 From: Brent Kimmel <bkimmel@users.noreply.github.com> Date: Mon, 23 Mar 2020 17:17:52 -0400 Subject: [PATCH 044/179] Resolver/nodedesign 25 (#60630) * PR base * adds designed resolver nodes * adjust distance between nodes * WIP remove stroke * WIP changes to meet mocks * new boxes * remove animation * new box assets * baby resolver running nodes complete * cleanup defs, add running trigger cube * added 2 more defs for process cubes * adding switched for assets on node component * vacuuming defs file * adjusting types and references to new event model * switch background to full shade for contrast * switch background to full shade for contrast * cube, animation and a11y changes to 25% nodes * PR base * adds designed resolver nodes * adjust distance between nodes * WIP remove stroke * WIP changes to meet mocks * new boxes * remove animation * new box assets * baby resolver running nodes complete * cleanup defs, add running trigger cube * added 2 more defs for process cubes * adding switched for assets on node component * vacuuming defs file * adjusting types and references to new event model * switch background to full shade for contrast * cube, animation and a11y changes to 25% nodes * merge upstream * change from Legacy to new Resolver event * cleaning up unused styles * fix adjacency map issues * fix process type to cube mapping * fix typing on selctor * set viewport to strict * remove unused types * fixes ci / testing issues * feedback from Jon Buttner * fix index from Jon Buttner comment * reset focus state on nodes * Robert review: changing adjacency map property names for better semantics * Robert Austin review: changing var name * Robert Austin review: rearrange code for readability * Robert Austin review: change const name * Robert Austin review: rearranging code for readability * Robert Austin review: adjustments to process_event_dot * Robert Austin review: replace level getter * Robert Austin review: removing unnecessary casting * Robert Austin review: adjust selector * Robert Austin review: fix setting parent map * Robert Austin review: replace function with consts * K Qualters review: change return type of function Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../resolver/models/indexed_process_tree.ts | 82 +++- .../embeddables/resolver/store/actions.ts | 15 +- .../data/__snapshots__/graphing.test.ts.snap | 116 +++--- .../resolver/store/data/selectors.ts | 42 +- .../embeddables/resolver/store/reducer.ts | 16 +- .../embeddables/resolver/store/selectors.ts | 5 + .../public/embeddables/resolver/types.ts | 36 ++ .../public/embeddables/resolver/view/defs.tsx | 381 ++++++++++++++++++ .../embeddables/resolver/view/edge_line.tsx | 3 +- .../embeddables/resolver/view/index.tsx | 63 ++- .../resolver/view/process_event_dot.tsx | 245 ++++++++++- 11 files changed, 887 insertions(+), 117 deletions(-) create mode 100644 x-pack/plugins/endpoint/public/embeddables/resolver/view/defs.tsx diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/models/indexed_process_tree.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/models/indexed_process_tree.ts index c9a03f0a47968..967a2c10f14c3 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/models/indexed_process_tree.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/models/indexed_process_tree.ts @@ -5,7 +5,7 @@ */ import { uniquePidForProcess, uniqueParentPidForProcess } from './process_event'; -import { IndexedProcessTree } from '../types'; +import { IndexedProcessTree, AdjacentProcessMap } from '../types'; import { ResolverEvent } from '../../../../common/types'; import { levelOrder as baseLevelOrder } from '../lib/tree_sequencers'; @@ -15,21 +15,89 @@ import { levelOrder as baseLevelOrder } from '../lib/tree_sequencers'; export function factory(processes: ResolverEvent[]): IndexedProcessTree { const idToChildren = new Map<string | undefined, ResolverEvent[]>(); const idToValue = new Map<string, ResolverEvent>(); + const idToAdjacent = new Map<string, AdjacentProcessMap>(); + + function emptyAdjacencyMap(id: string): AdjacentProcessMap { + return { + self: id, + parent: null, + firstChild: null, + previousSibling: null, + nextSibling: null, + level: 1, + }; + } + + const roots: ResolverEvent[] = []; for (const process of processes) { - idToValue.set(uniquePidForProcess(process), process); + const uniqueProcessPid = uniquePidForProcess(process); + idToValue.set(uniqueProcessPid, process); + + const currentProcessAdjacencyMap: AdjacentProcessMap = + idToAdjacent.get(uniqueProcessPid) || emptyAdjacencyMap(uniqueProcessPid); + idToAdjacent.set(uniqueProcessPid, currentProcessAdjacencyMap); + const uniqueParentPid = uniqueParentPidForProcess(process); - const processChildren = idToChildren.get(uniqueParentPid); - if (processChildren) { - processChildren.push(process); + const currentProcessSiblings = idToChildren.get(uniqueParentPid); + + if (currentProcessSiblings) { + const previousProcessId = uniquePidForProcess( + currentProcessSiblings[currentProcessSiblings.length - 1] + ); + currentProcessSiblings.push(process); + /** + * Update adjacency maps for current and previous entries + */ + idToAdjacent.get(previousProcessId)!.nextSibling = uniqueProcessPid; + currentProcessAdjacencyMap.previousSibling = previousProcessId; + if (uniqueParentPid) { + currentProcessAdjacencyMap.parent = uniqueParentPid; + } } else { idToChildren.set(uniqueParentPid, [process]); + + if (uniqueParentPid) { + /** + * Get the parent's map, otherwise set an empty one + */ + const parentAdjacencyMap = + idToAdjacent.get(uniqueParentPid) || + (idToAdjacent.set(uniqueParentPid, emptyAdjacencyMap(uniqueParentPid)), + idToAdjacent.get(uniqueParentPid))!; + // set firstChild for parent + parentAdjacencyMap.firstChild = uniqueProcessPid; + // set parent for current + currentProcessAdjacencyMap.parent = uniqueParentPid || null; + } else { + // In this case (no unique parent id), it must be a root + roots.push(process); + } } } + /** + * Scan adjacency maps from the top down and assign levels + */ + function traverseLevels(currentProcessMap: AdjacentProcessMap, level: number = 1): void { + const nextLevel = level + 1; + if (currentProcessMap.nextSibling) { + traverseLevels(idToAdjacent.get(currentProcessMap.nextSibling)!, level); + } + if (currentProcessMap.firstChild) { + traverseLevels(idToAdjacent.get(currentProcessMap.firstChild)!, nextLevel); + } + currentProcessMap.level = level; + } + + for (const treeRoot of roots) { + traverseLevels(idToAdjacent.get(uniquePidForProcess(treeRoot))!); + } + return { idToChildren, idToProcess: idToValue, + idToAdjacent, }; } @@ -38,8 +106,8 @@ export function factory(processes: ResolverEvent[]): IndexedProcessTree { */ export function children(tree: IndexedProcessTree, process: ResolverEvent): ResolverEvent[] { const id = uniquePidForProcess(process); - const processChildren = tree.idToChildren.get(id); - return processChildren === undefined ? [] : processChildren; + const currentProcessSiblings = tree.idToChildren.get(id); + return currentProcessSiblings === undefined ? [] : currentProcessSiblings; } /** diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts index fec2078cc60c9..ceb5da2ca9098 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts @@ -43,10 +43,23 @@ interface UserChangedSelectedEvent { interface AppRequestedResolverData { readonly type: 'appRequestedResolverData'; } +/** + * When the user switches the active descendent of the Resolver. + */ +interface UserFocusedOnResolverNode { + readonly type: 'userFocusedOnResolverNode'; + readonly payload: { + /** + * Used to identify the process node that should be brought into view. + */ + readonly nodeId: string; + }; +} export type ResolverAction = | CameraAction | DataAction | UserBroughtProcessIntoView | UserChangedSelectedEvent - | AppRequestedResolverData; + | AppRequestedResolverData + | UserFocusedOnResolverNode; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/__snapshots__/graphing.test.ts.snap b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/__snapshots__/graphing.test.ts.snap index b88652097eb5c..00abc27b25a83 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/__snapshots__/graphing.test.ts.snap +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/__snapshots__/graphing.test.ts.snap @@ -41,128 +41,128 @@ Object { -0.8164965809277259, ], Array [ - 35.35533905932738, - -21.228911104120876, + 70.71067811865476, + -41.641325627314025, ], ], Array [ Array [ - -35.35533905932738, - -62.053740150507174, + -70.71067811865476, + -123.29098372008661, ], Array [ - 106.06601717798213, - 19.595917942265423, + 212.13203435596427, + 40.00833246545857, ], ], Array [ Array [ - -35.35533905932738, - -62.053740150507174, + -70.71067811865476, + -123.29098372008661, ], Array [ 0, - -82.46615467370032, + -164.1158127664729, ], ], Array [ Array [ - 106.06601717798213, - 19.595917942265423, + 212.13203435596427, + 40.00833246545857, ], Array [ - 141.4213562373095, + 282.842712474619, -0.8164965809277259, ], ], Array [ Array [ 0, - -82.46615467370032, + -164.1158127664729, ], Array [ - 35.35533905932738, - -102.87856919689347, + 70.71067811865476, + -204.9406418128592, ], ], Array [ Array [ 0, - -123.2909837200866, + -245.76547085924548, ], Array [ - 70.71067811865476, - -82.46615467370032, + 141.4213562373095, + -164.1158127664729, ], ], Array [ Array [ 0, - -123.2909837200866, + -245.76547085924548, ], Array [ - 35.35533905932738, - -143.70339824327976, + 70.71067811865476, + -286.5902999056318, ], ], Array [ Array [ - 70.71067811865476, - -82.46615467370032, + 141.4213562373095, + -164.1158127664729, ], Array [ - 106.06601717798213, - -102.87856919689347, + 212.13203435596427, + -204.9406418128592, ], ], Array [ Array [ - 141.4213562373095, + 282.842712474619, -0.8164965809277259, ], Array [ - 176.7766952966369, - -21.22891110412087, + 353.5533905932738, + -41.64132562731401, ], ], Array [ Array [ - 141.4213562373095, - -41.64132562731402, + 282.842712474619, + -82.4661546737003, ], Array [ - 212.13203435596427, + 424.26406871192853, -0.8164965809277259, ], ], Array [ Array [ - 141.4213562373095, - -41.64132562731402, + 282.842712474619, + -82.4661546737003, ], Array [ - 176.7766952966369, - -62.053740150507174, + 353.5533905932738, + -123.29098372008661, ], ], Array [ Array [ - 212.13203435596427, + 424.26406871192853, -0.8164965809277259, ], Array [ - 247.48737341529164, - -21.228911104120883, + 494.9747468305833, + -41.64132562731404, ], ], Array [ Array [ - 247.48737341529164, - -21.228911104120883, + 494.9747468305833, + -41.64132562731404, ], Array [ - 318.1980515339464, - -62.05374015050717, + 636.3961030678928, + -123.2909837200866, ], ], ], @@ -199,7 +199,7 @@ Object { }, } => Array [ 0, - -82.46615467370032, + -164.1158127664729, ], Object { "@timestamp": 1582233383000, @@ -215,7 +215,7 @@ Object { "unique_ppid": 0, }, } => Array [ - 141.4213562373095, + 282.842712474619, -0.8164965809277259, ], Object { @@ -232,8 +232,8 @@ Object { "unique_ppid": 1, }, } => Array [ - 35.35533905932738, - -143.70339824327976, + 70.71067811865476, + -286.5902999056318, ], Object { "@timestamp": 1582233383000, @@ -249,8 +249,8 @@ Object { "unique_ppid": 1, }, } => Array [ - 106.06601717798213, - -102.87856919689347, + 212.13203435596427, + -204.9406418128592, ], Object { "@timestamp": 1582233383000, @@ -266,8 +266,8 @@ Object { "unique_ppid": 2, }, } => Array [ - 176.7766952966369, - -62.053740150507174, + 353.5533905932738, + -123.29098372008661, ], Object { "@timestamp": 1582233383000, @@ -283,8 +283,8 @@ Object { "unique_ppid": 2, }, } => Array [ - 247.48737341529164, - -21.228911104120883, + 494.9747468305833, + -41.64132562731404, ], Object { "@timestamp": 1582233383000, @@ -300,8 +300,8 @@ Object { "unique_ppid": 6, }, } => Array [ - 318.1980515339464, - -62.05374015050717, + 636.3961030678928, + -123.2909837200866, ], }, } @@ -316,8 +316,8 @@ Object { -0.8164965809277259, ], Array [ - 70.71067811865476, - -41.641325627314025, + 141.4213562373095, + -82.46615467370032, ], ], ], @@ -353,8 +353,8 @@ Object { "unique_ppid": 0, }, } => Array [ - 70.71067811865476, - -41.641325627314025, + 141.4213562373095, + -82.46615467370032, ], }, } diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts index e8007f82e30c2..5dda54d4ed029 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts @@ -13,11 +13,12 @@ import { EdgeLineSegment, ProcessWithWidthMetadata, Matrix3, + AdjacentProcessMap, } from '../../types'; import { ResolverEvent } from '../../../../../common/types'; import { Vector2 } from '../../types'; import { add as vector2Add, applyMatrix3 } from '../../lib/vector2'; -import { isGraphableProcess } from '../../models/process_event'; +import { isGraphableProcess, uniquePidForProcess } from '../../models/process_event'; import { factory as indexedProcessTreeFactory, children as indexedProcessTreeChildren, @@ -27,7 +28,7 @@ import { } from '../../models/indexed_process_tree'; const unit = 100; -const distanceBetweenNodesInUnits = 1; +const distanceBetweenNodesInUnits = 2; export function isLoading(state: DataState) { return state.isLoading; @@ -392,17 +393,42 @@ function processPositions( return positions; } -export const processNodePositionsAndEdgeLineSegments = createSelector( +export const indexedProcessTree = createSelector(graphableProcesses, function indexedTree( + /* eslint-disable no-shadow */ + graphableProcesses + /* eslint-enable no-shadow */ +) { + return indexedProcessTreeFactory(graphableProcesses); +}); + +export const processAdjacencies = createSelector( + indexedProcessTree, graphableProcesses, - function processNodePositionsAndEdgeLineSegments( + function selectProcessAdjacencies( /* eslint-disable no-shadow */ + indexedProcessTree, graphableProcesses /* eslint-enable no-shadow */ ) { - /** - * Index the tree, creating maps from id -> node and id -> children - */ - const indexedProcessTree = indexedProcessTreeFactory(graphableProcesses); + const processToAdjacencyMap = new Map<ResolverEvent, AdjacentProcessMap>(); + const { idToAdjacent } = indexedProcessTree; + + for (const graphableProcess of graphableProcesses) { + const processPid = uniquePidForProcess(graphableProcess); + const adjacencyMap = idToAdjacent.get(processPid)!; + processToAdjacencyMap.set(graphableProcess, adjacencyMap); + } + return { processToAdjacencyMap }; + } +); + +export const processNodePositionsAndEdgeLineSegments = createSelector( + indexedProcessTree, + function processNodePositionsAndEdgeLineSegments( + /* eslint-disable no-shadow */ + indexedProcessTree + /* eslint-enable no-shadow */ + ) { /** * Walk the tree in reverse level order, calculating the 'width' of subtrees. */ diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/reducer.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/reducer.ts index 20c490b8998f9..1c66a998a4c22 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/reducer.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/reducer.ts @@ -7,11 +7,25 @@ import { Reducer, combineReducers } from 'redux'; import { animateProcessIntoView } from './methods'; import { cameraReducer } from './camera/reducer'; import { dataReducer } from './data/reducer'; -import { ResolverState, ResolverAction } from '../types'; +import { ResolverState, ResolverAction, ResolverUIState } from '../types'; + +const uiReducer: Reducer<ResolverUIState, ResolverAction> = ( + uiState = { activeDescendentId: null }, + action +) => { + if (action.type === 'userFocusedOnResolverNode') { + return { + activeDescendentId: action.payload.nodeId, + }; + } else { + return uiState; + } +}; const concernReducers = combineReducers({ camera: cameraReducer, data: dataReducer, + ui: uiReducer, }); export const resolverReducer: Reducer<ResolverState, ResolverAction> = (state, action) => { diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts index 708eb684ebd3e..37482916496e7 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts @@ -54,6 +54,11 @@ export const processNodePositionsAndEdgeLineSegments = composeSelectors( dataSelectors.processNodePositionsAndEdgeLineSegments ); +export const processAdjacencies = composeSelectors( + dataStateSelector, + dataSelectors.processAdjacencies +); + /** * Returns the camera state from within ResolverState */ diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts index 4380d3ab98999..674553aba0937 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts @@ -23,6 +23,21 @@ export interface ResolverState { * Contains the state associated with event data (process events and possibly other event types). */ readonly data: DataState; + + /** + * Contains the state needed to maintain Resolver UI elements. + */ + readonly ui: ResolverUIState; +} + +/** + * Piece of redux state that models an animation for the camera. + */ +export interface ResolverUIState { + /** + * The ID attribute of the resolver's aria-activedescendent. + */ + readonly activeDescendentId: string | null; } /** @@ -174,9 +189,26 @@ export interface ProcessEvent { source_id?: number; process_name: string; process_path: string; + signature_status?: string; }; } +/** + * A map of Process Ids that indicate which processes are adjacent to a given process along + * directions in two axes: up/down and previous/next. + */ +export interface AdjacentProcessMap { + readonly self: string; + parent: string | null; + firstChild: string | null; + previousSibling: string | null; + nextSibling: string | null; + /** + * To support aria-level, this must be >= 1 + */ + level: number; +} + /** * A represention of a process tree with indices for O(1) access to children and values by id. */ @@ -189,6 +221,10 @@ export interface IndexedProcessTree { * Map of ID to process */ idToProcess: Map<string, ResolverEvent>; + /** + * Map of ID to adjacent processes + */ + idToAdjacent: Map<string, AdjacentProcessMap>; } /** diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/defs.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/defs.tsx new file mode 100644 index 0000000000000..799f67123ba26 --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/defs.tsx @@ -0,0 +1,381 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { saturate, lighten } from 'polished'; + +import { + htmlIdGenerator, + euiPaletteForTemperature, + euiPaletteForStatus, + colorPalette, +} from '@elastic/eui'; + +/** + * Generating from `colorPalette` function: This could potentially + * pick up a palette shift and decouple from raw hex + */ +const [euiColorEmptyShade, , , , , euiColor85Shade, euiColorFullShade] = colorPalette( + ['#ffffff', '#000000'], + 7 +); + +/** + * Base Colors - sourced from EUI + */ +const resolverPalette: Record<string, string | string[]> = { + temperatures: euiPaletteForTemperature(7), + statii: euiPaletteForStatus(7), + fullShade: euiColorFullShade, + emptyShade: euiColorEmptyShade, +}; + +/** + * Defines colors by semantics like so: + * `danger`, `attention`, `enabled`, `disabled` + * Or by function like: + * `colorBlindBackground`, `subMenuForeground` + */ +type ResolverColorNames = + | 'ok' + | 'empty' + | 'full' + | 'warning' + | 'strokeBehindEmpty' + | 'resolverBackground' + | 'runningProcessStart' + | 'runningProcessEnd' + | 'runningTriggerStart' + | 'runningTriggerEnd' + | 'activeNoWarning' + | 'activeWarning' + | 'fullLabelBackground' + | 'inertDescription'; + +export const NamedColors: Record<ResolverColorNames, string> = { + ok: saturate(0.5, resolverPalette.temperatures[0]), + empty: euiColorEmptyShade, + full: euiColorFullShade, + strokeBehindEmpty: euiColor85Shade, + warning: resolverPalette.statii[3], + resolverBackground: euiColorFullShade, + runningProcessStart: '#006BB4', + runningProcessEnd: '#017D73', + runningTriggerStart: '#BD281E', + runningTriggerEnd: '#DD0A73', + activeNoWarning: '#0078FF', + activeWarning: '#C61F38', + fullLabelBackground: '#3B3C41', + inertDescription: '#747474', +}; + +const idGenerator = htmlIdGenerator(); + +/** + * Ids of paint servers to be referenced by fill and stroke attributes + */ +export const PaintServerIds = { + runningProcess: idGenerator('psRunningProcess'), + runningTrigger: idGenerator('psRunningTrigger'), + runningProcessCube: idGenerator('psRunningProcessCube'), + runningTriggerCube: idGenerator('psRunningTriggerCube'), + terminatedProcessCube: idGenerator('psTerminatedProcessCube'), + terminatedTriggerCube: idGenerator('psTerminatedTriggerCube'), +}; + +/** + * PaintServers: Where color palettes, grandients, patterns and other similar concerns + * are exposed to the component + */ +const PaintServers = memo(() => ( + <> + <linearGradient + id={PaintServerIds.runningProcess} + x1="0" + y1="0" + x2="1" + y2="0" + spreadMethod="reflect" + gradientUnits="objectBoundingBox" + > + <stop + offset="0%" + stopColor={saturate(0.7, lighten(0.05, NamedColors.runningProcessStart))} + stopOpacity="1" + /> + <stop + offset="100%" + stopColor={saturate(0.7, lighten(0.05, NamedColors.runningProcessEnd))} + stopOpacity="1" + /> + </linearGradient> + <linearGradient + id={PaintServerIds.runningTrigger} + x1="0" + y1="0" + x2="1" + y2="0" + spreadMethod="reflect" + gradientUnits="objectBoundingBox" + > + <stop + offset="0%" + stopColor={saturate(0.7, lighten(0.05, NamedColors.runningTriggerStart))} + stopOpacity="1" + /> + <stop + offset="100%" + stopColor={saturate(0.7, lighten(0.05, NamedColors.runningTriggerEnd))} + stopOpacity="1" + /> + </linearGradient> + <linearGradient + id={PaintServerIds.runningProcessCube} + x1="-382.33074" + y1="265.24689" + x2="-381.88086" + y2="264.46019" + gradientTransform="matrix(88, 0, 0, -100, 33669, 26535)" + gradientUnits="userSpaceOnUse" + > + <stop offset="0" stopColor={NamedColors.runningProcessStart} /> + <stop offset="1" stopColor={NamedColors.runningProcessEnd} /> + </linearGradient> + <linearGradient + id={PaintServerIds.runningTriggerCube} + x1="-382.32713" + y1="265.24057" + x2="-381.88108" + y2="264.46057" + gradientTransform="matrix(88, 0, 0, -100, 33669, 26535)" + gradientUnits="userSpaceOnUse" + > + <stop offset="0" stopColor="#bd281f" /> + <stop offset="1" stopColor="#dc0b72" /> + </linearGradient> + <linearGradient + id={PaintServerIds.terminatedProcessCube} + x1="-382.33074" + y1="265.24689" + x2="-381.88086" + y2="264.46019" + gradientTransform="matrix(88, 0, 0, -100, 33669, 26535)" + gradientUnits="userSpaceOnUse" + > + <stop offset="0" stopColor="#006bb4" /> + <stop offset="1" stopColor="#017d73" /> + </linearGradient> + <linearGradient + id={PaintServerIds.terminatedTriggerCube} + x1="-382.33074" + y1="265.24689" + x2="-381.88086" + y2="264.46019" + gradientTransform="matrix(88, 0, 0, -100, 33669, 26535)" + gradientUnits="userSpaceOnUse" + > + <stop offset="0" stopColor="#be2820" /> + <stop offset="1" stopColor="#dc0b72" /> + </linearGradient> + </> +)); + +/** + * Ids of symbols to be linked by <use> elements + */ +export const SymbolIds = { + processNode: idGenerator('nodeSymbol'), + solidHexagon: idGenerator('hexagon'), + runningProcessCube: idGenerator('runningCube'), + runningTriggerCube: idGenerator('runningTriggerCube'), + terminatedProcessCube: idGenerator('terminatedCube'), + terminatedTriggerCube: idGenerator('terminatedTriggerCube'), +}; + +/** + * Defs entries that define shapes, masks and other spatial elements + */ +const SymbolsAndShapes = memo(() => ( + <> + <symbol id={SymbolIds.processNode} viewBox="0 0 144 25" preserveAspectRatio="xMidYMid meet"> + <rect + x="1" + y="1" + width="142" + height="23" + fill="inherit" + strokeWidth="0" + paintOrder="normal" + /> + </symbol> + <symbol id={SymbolIds.solidHexagon} viewBox="0 0 200 200" preserveAspectRatio="xMidYMid meet"> + <g transform="translate(0,-97)"> + <path + transform="matrix(1.6461 0 0 1.6596 -56.401 -64.183)" + d="m95.148 97.617 28.238 16.221 23.609 13.713 0.071 32.566-0.071 27.302-28.167 16.344-23.68 13.59-28.238-16.221-23.609-13.713-0.07098-32.566 0.07098-27.302 28.167-16.344z" + fill="inherit" + strokeWidth="15" + stroke="inherit" + /> + </g> + </symbol> + <symbol id={SymbolIds.runningProcessCube} viewBox="0 0 88 100"> + <title>Running Process + + + + + + + + + + + + + Running Trigger Process + + + + + + + + + + + + + Terminated Process + + + + + + + + + + Terminated Trigger Process + + + + + + + + + +)); + +/** + * This element is used to define the reusable assets for the Resolver + * It confers sevral advantages, including but not limited to: + * 1) Freedom of form for creative assets (beyond box-model constraints) + * 2) Separation of concerns between creative assets and more functional areas of the app + * 3) elements can be handled by compositor (faster) + */ +export const SymbolDefinitions = memo(() => ( + + + + + + +)); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/edge_line.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/edge_line.tsx index 3386ed4a448d5..fbd40dda9adfd 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/edge_line.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/edge_line.tsx @@ -66,7 +66,7 @@ export const EdgeLine = styled( */ transform: `translateY(-50%) rotateZ(${angle(screenStart, screenEnd)}rad)`, }; - return
; + return
; } ) )` @@ -74,4 +74,5 @@ export const EdgeLine = styled( height: 3px; background-color: #d4d4d4; color: #333333; + contain: strict; `; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx index eab22f993d0a8..22e9d05ad98ff 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx @@ -14,6 +14,7 @@ import { Panel } from './panel'; import { GraphControls } from './graph_controls'; import { ProcessEventDot } from './process_event_dot'; import { useCamera } from './use_camera'; +import { SymbolDefinitions, NamedColors } from './defs'; import { ResolverAction } from '../types'; import { ResolverEvent } from '../../../../common/types'; @@ -33,6 +34,14 @@ const StyledGraphControls = styled(GraphControls)` right: 5px; `; +const StyledResolverContainer = styled.div` + display: flex; + flex-grow: 1; + contain: layout; +`; + +const bgColor = NamedColors.resolverBackground; + export const Resolver = styled( React.memo(function Resolver({ className, @@ -46,6 +55,8 @@ export const Resolver = styled( ); const dispatch: (action: ResolverAction) => unknown = useDispatch(); + const { processToAdjacencyMap } = useSelector(selectors.processAdjacencies); + const { projectionMatrix, ref, onMouseDown } = useCamera(); const isLoading = useSelector(selectors.isLoading); @@ -62,29 +73,35 @@ export const Resolver = styled(
) : ( - <> -
- {Array.from(processNodePositions).map(([processEvent, position], index) => ( - - ))} - {edgeLineSegments.map(([startPosition, endPosition], index) => ( - - ))} -
- - - + + {edgeLineSegments.map(([startPosition, endPosition], index) => ( + + ))} + {Array.from(processNodePositions).map(([processEvent, position], index) => ( + + ))} + )} + + +
); }) @@ -111,4 +128,6 @@ export const Resolver = styled( * Prevent partially visible components from showing up outside the bounds of Resolver. */ overflow: hidden; + contain: strict; + background-color: ${bgColor}; `; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx index 2241df97291ae..f3086be1598a8 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx @@ -4,12 +4,52 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { htmlIdGenerator, EuiKeyboardAccessible } from '@elastic/eui'; import { applyMatrix3 } from '../lib/vector2'; -import { Vector2, Matrix3 } from '../types'; +import { Vector2, Matrix3, AdjacentProcessMap, ResolverProcessType } from '../types'; +import { SymbolIds, NamedColors, PaintServerIds } from './defs'; import { ResolverEvent } from '../../../../common/types'; +import { useResolverDispatch } from './use_resolver_dispatch'; import * as eventModel from '../../../../common/models/event'; +import * as processModel from '../models/process_event'; + +const nodeAssets = { + runningProcessCube: { + cubeSymbol: `#${SymbolIds.runningProcessCube}`, + labelFill: `url(#${PaintServerIds.runningProcess})`, + descriptionFill: NamedColors.activeNoWarning, + descriptionText: i18n.translate('xpack.endpoint.resolver.runningProcess', { + defaultMessage: 'Running Process', + }), + }, + runningTriggerCube: { + cubeSymbol: `#${SymbolIds.runningTriggerCube}`, + labelFill: `url(#${PaintServerIds.runningTrigger})`, + descriptionFill: NamedColors.activeWarning, + descriptionText: i18n.translate('xpack.endpoint.resolver.runningTrigger', { + defaultMessage: 'Running Trigger', + }), + }, + terminatedProcessCube: { + cubeSymbol: `#${SymbolIds.terminatedProcessCube}`, + labelFill: NamedColors.fullLabelBackground, + descriptionFill: NamedColors.inertDescription, + descriptionText: i18n.translate('xpack.endpoint.resolver.terminatedProcess', { + defaultMessage: 'Terminated Process', + }), + }, + terminatedTriggerCube: { + cubeSymbol: `#${SymbolIds.terminatedTriggerCube}`, + labelFill: NamedColors.fullLabelBackground, + descriptionFill: NamedColors.inertDescription, + descriptionText: i18n.translate('xpack.endpoint.resolver.terminatedTrigger', { + defaultMessage: 'Terminated Trigger', + }), + }, +}; /** * A placeholder view for a process node. @@ -21,6 +61,7 @@ export const ProcessEventDot = styled( position, event, projectionMatrix, + adjacentNodeMap, }: { /** * A `className` string provided by `styled` @@ -38,39 +79,205 @@ export const ProcessEventDot = styled( * projectionMatrix which can be used to convert `position` to screen coordinates. */ projectionMatrix: Matrix3; + /** + * map of what nodes are "adjacent" to this one in "up, down, previous, next" directions + */ + adjacentNodeMap?: AdjacentProcessMap; }) => { /** * Convert the position, which is in 'world' coordinates, to screen coordinates. */ const [left, top] = applyMatrix3(position, projectionMatrix); - const style = { - left: (left - 20).toString() + 'px', - top: (top - 20).toString() + 'px', - }; + + const [magFactorX] = projectionMatrix; + + const selfId = adjacentNodeMap?.self; + + const nodeViewportStyle = useMemo( + () => ({ + left: `${left}px`, + top: `${top}px`, + // Width of symbol viewport scaled to fit + width: `${360 * magFactorX}px`, + // Height according to symbol viewbox AR + height: `${120 * magFactorX}px`, + // Adjusted to position/scale with camera + transform: `translateX(-${0.172413 * 360 * magFactorX + 10}px) translateY(-${0.73684 * + 120 * + magFactorX}px)`, + }), + [left, magFactorX, top] + ); + + const markerBaseSize = 15; + const markerSize = markerBaseSize; + const markerPositionOffset = -markerBaseSize / 2; + + const labelYOffset = markerPositionOffset + 0.25 * markerSize - 0.5; + + const labelYHeight = markerSize / 1.7647; + + const levelAttribute = adjacentNodeMap?.level + ? { + 'aria-level': adjacentNodeMap.level, + } + : {}; + + const flowToAttribute = adjacentNodeMap?.nextSibling + ? { + 'aria-flowto': adjacentNodeMap.nextSibling, + } + : {}; + + const nodeType = getNodeType(event); + const clickTargetRef: { current: SVGAnimationElement | null } = React.createRef(); + const { cubeSymbol, labelFill, descriptionFill, descriptionText } = nodeAssets[nodeType]; + const resolverNodeIdGenerator = htmlIdGenerator('resolverNode'); + const [nodeId, labelId, descriptionId] = [ + !!selfId ? resolverNodeIdGenerator(String(selfId)) : resolverNodeIdGenerator(), + resolverNodeIdGenerator(), + resolverNodeIdGenerator(), + ] as string[]; + + const dispatch = useResolverDispatch(); + + const handleFocus = useCallback( + (focusEvent: React.FocusEvent) => { + dispatch({ + type: 'userFocusedOnResolverNode', + payload: { + nodeId, + }, + }); + focusEvent.currentTarget.setAttribute('aria-current', 'true'); + }, + [dispatch, nodeId] + ); + + const handleClick = useCallback( + (clickEvent: React.MouseEvent) => { + if (clickTargetRef.current !== null) { + (clickTargetRef.current as any).beginElement(); + } + }, + [clickTargetRef] + ); + return ( - - name: {eventModel.eventName(event)} -
- x: {position[0]} -
- y: {position[1]} -
+ + + + + + + + + {eventModel.eventName(event)} + + + {descriptionText} + + + + ); } ) )` position: absolute; - width: 40px; - height: 40px; + display: block; text-align: left; font-size: 10px; - /** - * Give the element a button-like appearance. - */ user-select: none; - border: 1px solid black; box-sizing: border-box; border-radius: 10%; padding: 4px; white-space: nowrap; + will-change: left, top, width, height; + contain: strict; `; + +const processTypeToCube: Record = { + processCreated: 'terminatedProcessCube', + processRan: 'runningProcessCube', + processTerminated: 'terminatedProcessCube', + unknownProcessEvent: 'runningProcessCube', + processCausedAlert: 'runningTriggerCube', + unknownEvent: 'runningProcessCube', +}; + +function getNodeType(processEvent: ResolverEvent): keyof typeof nodeAssets { + const processType = processModel.eventType(processEvent); + + if (processType in processTypeToCube) { + return processTypeToCube[processType]; + } + return 'runningProcessCube'; +} From fa69765e4bda11701f70e6a8dd34e0ff07c9c3da Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 23 Mar 2020 22:45:26 +0100 Subject: [PATCH 045/179] Implement Kibana Login Selector (#53010) --- x-pack/legacy/plugins/security/index.ts | 5 +- x-pack/plugins/security/common/login_state.ts | 20 + .../common/model/authenticated_user.mock.ts | 2 +- .../security/common/parse_next.test.ts | 17 + x-pack/plugins/security/common/parse_next.ts | 2 +- .../__snapshots__/login_page.test.tsx.snap | 113 ++- .../basic_login_form.test.tsx.snap | 95 --- .../basic_login_form.test.tsx | 111 --- .../basic_login_form/basic_login_form.tsx | 219 ----- .../authentication/login/components/index.ts | 2 +- .../__snapshots__/login_form.test.tsx.snap | 240 ++++++ .../{basic_login_form => login_form}/index.ts | 2 +- .../components/login_form/login_form.test.tsx | 272 +++++++ .../components/login_form/login_form.tsx | 343 ++++++++ .../authentication/login/login_app.test.ts | 6 +- .../public/authentication/login/login_app.ts | 4 +- .../authentication/login/login_page.test.tsx | 56 +- .../authentication/login/login_page.tsx | 180 +++-- .../authentication/authenticator.test.ts | 522 ++++++++++-- .../server/authentication/authenticator.ts | 278 ++++--- .../server/authentication/index.mock.ts | 2 +- .../server/authentication/index.test.ts | 28 +- .../security/server/authentication/index.ts | 4 +- .../authentication/providers/base.mock.ts | 3 +- .../server/authentication/providers/base.ts | 11 +- .../authentication/providers/basic.test.ts | 19 +- .../server/authentication/providers/basic.ts | 23 +- .../authentication/providers/http.test.ts | 2 +- .../server/authentication/providers/index.ts | 4 +- .../authentication/providers/kerberos.test.ts | 178 ++-- .../authentication/providers/kerberos.ts | 33 +- .../authentication/providers/oidc.test.ts | 195 ++++- .../server/authentication/providers/oidc.ts | 118 ++- .../authentication/providers/pki.test.ts | 313 ++++---- .../server/authentication/providers/pki.ts | 31 +- .../authentication/providers/saml.test.ts | 275 +++++-- .../server/authentication/providers/saml.ts | 173 ++-- .../authentication/providers/token.test.ts | 45 +- .../server/authentication/providers/token.ts | 33 +- x-pack/plugins/security/server/config.test.ts | 757 +++++++++++++++++- x-pack/plugins/security/server/config.ts | 246 +++++- x-pack/plugins/security/server/index.ts | 26 +- x-pack/plugins/security/server/plugin.test.ts | 6 +- x-pack/plugins/security/server/plugin.ts | 20 +- .../routes/authentication/basic.test.ts | 14 +- .../server/routes/authentication/basic.ts | 7 +- .../routes/authentication/common.test.ts | 264 +++++- .../server/routes/authentication/common.ts | 66 +- .../server/routes/authentication/index.ts | 6 +- .../server/routes/authentication/oidc.ts | 21 +- .../server/routes/authentication/saml.test.ts | 12 +- .../server/routes/authentication/saml.ts | 28 +- .../security/server/routes/index.mock.ts | 8 +- .../routes/users/change_password.test.ts | 6 +- .../server/routes/users/change_password.ts | 2 +- .../server/routes/views/index.test.ts | 30 +- .../security/server/routes/views/index.ts | 6 +- .../server/routes/views/login.test.ts | 197 ++++- .../security/server/routes/views/login.ts | 36 +- x-pack/scripts/functional_tests.js | 1 + .../apis/security/kerberos_login.ts | 10 +- .../fixtures/kerberos_tools.ts | 13 + .../apis/index.ts | 14 + .../apis/login_selector.ts | 545 +++++++++++++ .../login_selector_api_integration/config.ts | 141 ++++ .../ftr_provider_context.d.ts} | 9 +- .../services.ts | 14 + .../apis/security/pki_auth.ts | 1 - x-pack/test/pki_api_integration/config.ts | 1 - .../apis/security/saml_login.ts | 43 +- x-pack/test/saml_api_integration/config.ts | 2 +- .../fixtures/idp_metadata.xml | 2 +- .../fixtures/idp_metadata_2.xml | 41 + .../fixtures/saml_tools.ts | 17 +- 74 files changed, 5201 insertions(+), 1390 deletions(-) create mode 100644 x-pack/plugins/security/common/login_state.ts delete mode 100644 x-pack/plugins/security/public/authentication/login/components/basic_login_form/__snapshots__/basic_login_form.test.tsx.snap delete mode 100644 x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.test.tsx delete mode 100644 x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.tsx create mode 100644 x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap rename x-pack/plugins/security/public/authentication/login/components/{basic_login_form => login_form}/index.ts (82%) create mode 100644 x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx create mode 100644 x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx create mode 100644 x-pack/test/kerberos_api_integration/fixtures/kerberos_tools.ts create mode 100644 x-pack/test/login_selector_api_integration/apis/index.ts create mode 100644 x-pack/test/login_selector_api_integration/apis/login_selector.ts create mode 100644 x-pack/test/login_selector_api_integration/config.ts rename x-pack/{plugins/security/public/authentication/login/login_state.ts => test/login_selector_api_integration/ftr_provider_context.d.ts} (56%) create mode 100644 x-pack/test/login_selector_api_integration/services.ts create mode 100644 x-pack/test/saml_api_integration/fixtures/idp_metadata_2.xml diff --git a/x-pack/legacy/plugins/security/index.ts b/x-pack/legacy/plugins/security/index.ts index deebbccf5aa49..5b2218af1fd52 100644 --- a/x-pack/legacy/plugins/security/index.ts +++ b/x-pack/legacy/plugins/security/index.ts @@ -51,10 +51,7 @@ export const security = (kibana: Record) => uiExports: { hacks: ['plugins/security/hacks/legacy'], injectDefaultVars: (server: Server) => { - return { - secureCookies: getSecurityPluginSetup(server).__legacyCompat.config.secureCookies, - enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled'), - }; + return { enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled') }; }, }, diff --git a/x-pack/plugins/security/common/login_state.ts b/x-pack/plugins/security/common/login_state.ts new file mode 100644 index 0000000000000..4342e82d2f90b --- /dev/null +++ b/x-pack/plugins/security/common/login_state.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LoginLayout } from './licensing'; + +export interface LoginSelector { + enabled: boolean; + providers: Array<{ type: string; name: string; description?: string }>; +} + +export interface LoginState { + layout: LoginLayout; + allowLogin: boolean; + showLoginForm: boolean; + requiresSecureConnection: boolean; + selector: LoginSelector; +} diff --git a/x-pack/plugins/security/common/model/authenticated_user.mock.ts b/x-pack/plugins/security/common/model/authenticated_user.mock.ts index 220b284e76591..f8b0d27efcbf4 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.mock.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.mock.ts @@ -15,7 +15,7 @@ export function mockAuthenticatedUser(user: Partial = {}) { enabled: true, authentication_realm: { name: 'native1', type: 'native' }, lookup_realm: { name: 'native1', type: 'native' }, - authentication_provider: 'basic', + authentication_provider: 'basic1', ...user, }; } diff --git a/x-pack/plugins/security/common/parse_next.test.ts b/x-pack/plugins/security/common/parse_next.test.ts index b5e6c7dca41d8..11a843d397ded 100644 --- a/x-pack/plugins/security/common/parse_next.test.ts +++ b/x-pack/plugins/security/common/parse_next.test.ts @@ -34,6 +34,15 @@ describe('parseNext', () => { expect(parseNext(href, basePath)).toEqual(`${next}#${hash}`); }); + it('should properly handle multiple next with hash', () => { + const basePath = '/iqf'; + const next1 = `${basePath}/app/kibana`; + const next2 = `${basePath}/app/ml`; + const hash = '/discover/New-Saved-Search'; + const href = `${basePath}/login?next=${next1}&next=${next2}#${hash}`; + expect(parseNext(href, basePath)).toEqual(`${next1}#${hash}`); + }); + it('should properly decode special characters', () => { const basePath = '/iqf'; const next = `${encodeURIComponent(basePath)}%2Fapp%2Fkibana`; @@ -118,6 +127,14 @@ describe('parseNext', () => { expect(parseNext(href)).toEqual(`${next}#${hash}`); }); + it('should properly handle multiple next with hash', () => { + const next1 = '/app/kibana'; + const next2 = '/app/ml'; + const hash = '/discover/New-Saved-Search'; + const href = `/login?next=${next1}&next=${next2}#${hash}`; + expect(parseNext(href)).toEqual(`${next1}#${hash}`); + }); + it('should properly decode special characters', () => { const next = '%2Fapp%2Fkibana'; const hash = '/discover/New-Saved-Search'; diff --git a/x-pack/plugins/security/common/parse_next.ts b/x-pack/plugins/security/common/parse_next.ts index 834acd783abbe..7cbe335825a5a 100644 --- a/x-pack/plugins/security/common/parse_next.ts +++ b/x-pack/plugins/security/common/parse_next.ts @@ -40,5 +40,5 @@ export function parseNext(href: string, basePath = '') { return `${basePath}/`; } - return query.next + (hash || ''); + return next + (hash || ''); } diff --git a/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap index 30715be1db232..ecbdfedac1dd3 100644 --- a/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap +++ b/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap @@ -23,7 +23,7 @@ exports[`LoginPage disabled form states renders as expected when an unknown logi @@ -38,6 +38,25 @@ exports[`LoginPage disabled form states renders as expected when an unknown logi /> `; +exports[`LoginPage disabled form states renders as expected when login is not enabled 1`] = ` + + } + title={ + + } +/> +`; + exports[`LoginPage disabled form states renders as expected when secure connection is required but not present 1`] = ` `; exports[`LoginPage enabled form state renders as expected when info message is set 1`] = ` - `; exports[`LoginPage enabled form state renders as expected when loginAssistanceMessage is set 1`] = ` - `; @@ -172,7 +254,7 @@ exports[`LoginPage page renders as expected 1`] = ` gutterSize="l" > - diff --git a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/__snapshots__/basic_login_form.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/components/basic_login_form/__snapshots__/basic_login_form.test.tsx.snap deleted file mode 100644 index b09f398ed5ed9..0000000000000 --- a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/__snapshots__/basic_login_form.test.tsx.snap +++ /dev/null @@ -1,95 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`BasicLoginForm renders as expected 1`] = ` - - - - - -
- - } - labelType="label" - > - - - - } - labelType="label" - > - - - - - -
-
-
-`; diff --git a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.test.tsx b/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.test.tsx deleted file mode 100644 index e62fd7191dfae..0000000000000 --- a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { act } from '@testing-library/react'; -import { EuiButton, EuiCallOut } from '@elastic/eui'; -import { mountWithIntl, nextTick, shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { BasicLoginForm } from './basic_login_form'; - -import { coreMock } from '../../../../../../../../src/core/public/mocks'; - -describe('BasicLoginForm', () => { - beforeAll(() => { - Object.defineProperty(window, 'location', { - value: { href: 'https://some-host/bar' }, - writable: true, - }); - }); - - afterAll(() => { - delete (window as any).location; - }); - - it('renders as expected', () => { - expect( - shallowWithIntl( - - ) - ).toMatchSnapshot(); - }); - - it('renders an info message when provided.', () => { - const wrapper = shallowWithIntl( - - ); - - expect(wrapper.find(EuiCallOut).props().title).toEqual('Hey this is an info message'); - }); - - it('renders an invalid credentials message', async () => { - const mockHTTP = coreMock.createStart({ basePath: '/some-base-path' }).http; - mockHTTP.post.mockRejectedValue({ response: { status: 401 } }); - - const wrapper = mountWithIntl(); - - wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); - wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); - wrapper.find(EuiButton).simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find(EuiCallOut).props().title).toEqual( - `Invalid username or password. Please try again.` - ); - }); - - it('renders unknown error message', async () => { - const mockHTTP = coreMock.createStart({ basePath: '/some-base-path' }).http; - mockHTTP.post.mockRejectedValue({ response: { status: 500 } }); - - const wrapper = mountWithIntl(); - - wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); - wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); - wrapper.find(EuiButton).simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find(EuiCallOut).props().title).toEqual(`Oops! Error. Try again.`); - }); - - it('properly redirects after successful login', async () => { - window.location.href = `https://some-host/login?next=${encodeURIComponent( - '/some-base-path/app/kibana#/home?_g=()' - )}`; - const mockHTTP = coreMock.createStart({ basePath: '/some-base-path' }).http; - mockHTTP.post.mockResolvedValue({}); - - const wrapper = mountWithIntl(); - - wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username1' } }); - wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password1' } }); - wrapper.find(EuiButton).simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(mockHTTP.post).toHaveBeenCalledTimes(1); - expect(mockHTTP.post).toHaveBeenCalledWith('/internal/security/login', { - body: JSON.stringify({ username: 'username1', password: 'password1' }), - }); - - expect(window.location.href).toBe('/some-base-path/app/kibana#/home?_g=()'); - expect(wrapper.find(EuiCallOut).exists()).toBe(false); - }); -}); diff --git a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.tsx deleted file mode 100644 index 7302ee9bf9851..0000000000000 --- a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.tsx +++ /dev/null @@ -1,219 +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 React, { ChangeEvent, Component, FormEvent, Fragment, MouseEvent } from 'react'; -import ReactMarkdown from 'react-markdown'; -import { - EuiButton, - EuiCallOut, - EuiFieldText, - EuiFormRow, - EuiPanel, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { HttpStart, IHttpFetchError } from 'src/core/public'; -import { parseNext } from '../../../../../common/parse_next'; - -interface Props { - http: HttpStart; - infoMessage?: string; - loginAssistanceMessage: string; -} - -interface State { - hasError: boolean; - isLoading: boolean; - username: string; - password: string; - message: string; -} - -export class BasicLoginForm extends Component { - public state = { - hasError: false, - isLoading: false, - username: '', - password: '', - message: '', - }; - - public render() { - return ( - - {this.renderLoginAssistanceMessage()} - {this.renderMessage()} - -
- - } - > - - - - - } - > - - - - - - -
-
-
- ); - } - - private renderLoginAssistanceMessage = () => { - return ( - - - {this.props.loginAssistanceMessage} - - - ); - }; - - private renderMessage = () => { - if (this.state.message) { - return ( - - - - - ); - } - - if (this.props.infoMessage) { - return ( - - - - - ); - } - - return null; - }; - - private setUsernameInputRef(ref: HTMLInputElement) { - if (ref) { - ref.focus(); - } - } - - private isFormValid = () => { - const { username, password } = this.state; - - return username && password; - }; - - private onUsernameChange = (e: ChangeEvent) => { - this.setState({ - username: e.target.value, - }); - }; - - private onPasswordChange = (e: ChangeEvent) => { - this.setState({ - password: e.target.value, - }); - }; - - private submit = async (e: MouseEvent | FormEvent) => { - e.preventDefault(); - - if (!this.isFormValid()) { - return; - } - - this.setState({ - isLoading: true, - message: '', - }); - - const { http } = this.props; - const { username, password } = this.state; - - try { - await http.post('/internal/security/login', { body: JSON.stringify({ username, password }) }); - window.location.href = parseNext(window.location.href, http.basePath.serverBasePath); - } catch (error) { - const message = - (error as IHttpFetchError).response?.status === 401 - ? i18n.translate( - 'xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage', - { defaultMessage: 'Invalid username or password. Please try again.' } - ) - : i18n.translate('xpack.security.login.basicLoginForm.unknownErrorMessage', { - defaultMessage: 'Oops! Error. Try again.', - }); - - this.setState({ - hasError: true, - message, - isLoading: false, - }); - } - }; -} diff --git a/x-pack/plugins/security/public/authentication/login/components/index.ts b/x-pack/plugins/security/public/authentication/login/components/index.ts index 5f267f7c4caa2..3113a177d433e 100644 --- a/x-pack/plugins/security/public/authentication/login/components/index.ts +++ b/x-pack/plugins/security/public/authentication/login/components/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { BasicLoginForm } from './basic_login_form'; +export { LoginForm } from './login_form'; export { DisabledLoginForm } from './disabled_login_form'; diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap new file mode 100644 index 0000000000000..a25498a637c2f --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap @@ -0,0 +1,240 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LoginForm login selector renders as expected with login form 1`] = ` + + + Login w/SAML + + + + Login w/PKI + + + + ―――   + +   ――― + + + +
+ + } + labelType="label" + > + + + + } + labelType="label" + > + + + + + +
+
+
+`; + +exports[`LoginForm login selector renders as expected without login form for providers with and without description 1`] = ` + + + Login w/SAML + + + + + + + +`; + +exports[`LoginForm renders as expected 1`] = ` + + +
+ + } + labelType="label" + > + + + + } + labelType="label" + > + + + + + +
+
+
+`; diff --git a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/index.ts b/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts similarity index 82% rename from x-pack/plugins/security/public/authentication/login/components/basic_login_form/index.ts rename to x-pack/plugins/security/public/authentication/login/components/login_form/index.ts index 3c1350ba590a6..c09a8dad4945c 100644 --- a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/index.ts +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { BasicLoginForm } from './basic_login_form'; +export { LoginForm } from './login_form'; diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx new file mode 100644 index 0000000000000..c17c10a2c5148 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx @@ -0,0 +1,272 @@ +/* + * 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 from 'react'; +import { act } from '@testing-library/react'; +import { EuiButton, EuiCallOut } from '@elastic/eui'; +import { mountWithIntl, nextTick, shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { LoginForm } from './login_form'; + +import { coreMock } from '../../../../../../../../src/core/public/mocks'; + +describe('LoginForm', () => { + beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { href: 'https://some-host/bar' }, + writable: true, + }); + }); + + afterAll(() => { + delete (window as any).location; + }); + + it('renders as expected', () => { + const coreStartMock = coreMock.createStart(); + expect( + shallowWithIntl( + + ) + ).toMatchSnapshot(); + }); + + it('renders an info message when provided.', () => { + const coreStartMock = coreMock.createStart(); + const wrapper = shallowWithIntl( + + ); + + expect(wrapper.find(EuiCallOut).props().title).toEqual('Hey this is an info message'); + }); + + it('renders an invalid credentials message', async () => { + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockRejectedValue({ response: { status: 401 } }); + + const wrapper = mountWithIntl( + + ); + + wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); + wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); + wrapper.find(EuiButton).simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(EuiCallOut).props().title).toEqual( + `Invalid username or password. Please try again.` + ); + }); + + it('renders unknown error message', async () => { + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockRejectedValue({ response: { status: 500 } }); + + const wrapper = mountWithIntl( + + ); + + wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); + wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); + wrapper.find(EuiButton).simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(EuiCallOut).props().title).toEqual(`Oops! Error. Try again.`); + }); + + it('properly redirects after successful login', async () => { + window.location.href = `https://some-host/login?next=${encodeURIComponent( + '/some-base-path/app/kibana#/home?_g=()' + )}`; + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockResolvedValue({}); + + const wrapper = mountWithIntl( + + ); + + wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username1' } }); + wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password1' } }); + wrapper.find(EuiButton).simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); + expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', { + body: JSON.stringify({ username: 'username1', password: 'password1' }), + }); + + expect(window.location.href).toBe('/some-base-path/app/kibana#/home?_g=()'); + expect(wrapper.find(EuiCallOut).exists()).toBe(false); + }); + + describe('login selector', () => { + it('renders as expected with login form', async () => { + const coreStartMock = coreMock.createStart(); + expect( + shallowWithIntl( + + ) + ).toMatchSnapshot(); + }); + + it('renders as expected without login form for providers with and without description', async () => { + const coreStartMock = coreMock.createStart(); + expect( + shallowWithIntl( + + ) + ).toMatchSnapshot(); + }); + + it('properly redirects after successful login', async () => { + const currentURL = `https://some-host/login?next=${encodeURIComponent( + '/some-base-path/app/kibana#/home?_g=()' + )}`; + + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockResolvedValue({ + location: 'https://external-idp/login?optional-arg=2#optional-hash', + }); + + window.location.href = currentURL; + const wrapper = mountWithIntl( + + ); + + wrapper.findWhere(node => node.key() === 'saml1').simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); + expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login_with', { + body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }), + }); + + expect(window.location.href).toBe('https://external-idp/login?optional-arg=2#optional-hash'); + expect(wrapper.find(EuiCallOut).exists()).toBe(false); + expect(coreStartMock.notifications.toasts.addError).not.toHaveBeenCalled(); + }); + + it('shows error toast if login fails', async () => { + const currentURL = `https://some-host/login?next=${encodeURIComponent( + '/some-base-path/app/kibana#/home?_g=()' + )}`; + + const failureReason = new Error('Oh no!'); + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockRejectedValue(failureReason); + + window.location.href = currentURL; + const wrapper = mountWithIntl( + + ); + + wrapper.findWhere(node => node.key() === 'saml1').simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); + expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login_with', { + body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }), + }); + + expect(window.location.href).toBe(currentURL); + expect(coreStartMock.notifications.toasts.addError).toHaveBeenCalledWith(failureReason, { + title: 'Could not perform login.', + }); + }); + }); +}); diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx new file mode 100644 index 0000000000000..a028eb1ba4b70 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx @@ -0,0 +1,343 @@ +/* + * 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, { ChangeEvent, Component, FormEvent, Fragment, MouseEvent } from 'react'; +import ReactMarkdown from 'react-markdown'; +import { + EuiButton, + EuiCallOut, + EuiFieldText, + EuiFormRow, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { HttpStart, IHttpFetchError, NotificationsStart } from 'src/core/public'; +import { parseNext } from '../../../../../common/parse_next'; +import { LoginSelector } from '../../../../../common/login_state'; + +interface Props { + http: HttpStart; + notifications: NotificationsStart; + selector: LoginSelector; + showLoginForm: boolean; + infoMessage?: string; + loginAssistanceMessage: string; +} + +interface State { + loadingState: + | { type: LoadingStateType.None } + | { type: LoadingStateType.Form } + | { type: LoadingStateType.Selector; providerName: string }; + username: string; + password: string; + message: + | { type: MessageType.None } + | { type: MessageType.Danger | MessageType.Info; content: string }; +} + +enum LoadingStateType { + None, + Form, + Selector, +} + +enum MessageType { + None, + Info, + Danger, +} + +export class LoginForm extends Component { + public state: State = { + loadingState: { type: LoadingStateType.None }, + username: '', + password: '', + message: this.props.infoMessage + ? { type: MessageType.Info, content: this.props.infoMessage } + : { type: MessageType.None }, + }; + + public render() { + return ( + + {this.renderLoginAssistanceMessage()} + {this.renderMessage()} + {this.renderSelector()} + {this.renderLoginForm()} + + ); + } + + private renderLoginForm = () => { + if (!this.props.showLoginForm) { + return null; + } + + return ( + +
+ + } + > + + + + + } + > + + + + + + +
+
+ ); + }; + + private renderLoginAssistanceMessage = () => { + if (!this.props.loginAssistanceMessage) { + return null; + } + + return ( + + + {this.props.loginAssistanceMessage} + + + ); + }; + + private renderMessage = () => { + const { message } = this.state; + if (message.type === MessageType.Danger) { + return ( + + + + + ); + } + + if (message.type === MessageType.Info) { + return ( + + + + + ); + } + + return null; + }; + + private renderSelector = () => { + const showLoginSelector = + this.props.selector.enabled && this.props.selector.providers.length > 0; + if (!showLoginSelector) { + return null; + } + + const loginSelectorAndLoginFormSeparator = showLoginSelector && this.props.showLoginForm && ( + <> + + ―――   + +   ――― + + + + ); + + return ( + <> + {this.props.selector.providers.map((provider, index) => ( + + this.loginWithSelector(provider.type, provider.name)} + > + {provider.description ?? ( + + )} + + + + ))} + {loginSelectorAndLoginFormSeparator} + + ); + }; + + private setUsernameInputRef(ref: HTMLInputElement) { + if (ref) { + ref.focus(); + } + } + + private isFormValid = () => { + const { username, password } = this.state; + + return username && password; + }; + + private onUsernameChange = (e: ChangeEvent) => { + this.setState({ + username: e.target.value, + }); + }; + + private onPasswordChange = (e: ChangeEvent) => { + this.setState({ + password: e.target.value, + }); + }; + + private submitLoginForm = async ( + e: MouseEvent | FormEvent + ) => { + e.preventDefault(); + + if (!this.isFormValid()) { + return; + } + + this.setState({ + loadingState: { type: LoadingStateType.Form }, + message: { type: MessageType.None }, + }); + + const { http } = this.props; + const { username, password } = this.state; + + try { + await http.post('/internal/security/login', { body: JSON.stringify({ username, password }) }); + window.location.href = parseNext(window.location.href, http.basePath.serverBasePath); + } catch (error) { + const message = + (error as IHttpFetchError).response?.status === 401 + ? i18n.translate( + 'xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage', + { defaultMessage: 'Invalid username or password. Please try again.' } + ) + : i18n.translate('xpack.security.login.basicLoginForm.unknownErrorMessage', { + defaultMessage: 'Oops! Error. Try again.', + }); + + this.setState({ + message: { type: MessageType.Danger, content: message }, + loadingState: { type: LoadingStateType.None }, + }); + } + }; + + private loginWithSelector = async (providerType: string, providerName: string) => { + this.setState({ + loadingState: { type: LoadingStateType.Selector, providerName }, + message: { type: MessageType.None }, + }); + + try { + const { location } = await this.props.http.post<{ location: string }>( + '/internal/security/login_with', + { body: JSON.stringify({ providerType, providerName, currentURL: window.location.href }) } + ); + + window.location.href = location; + } catch (err) { + this.props.notifications.toasts.addError(err, { + title: i18n.translate('xpack.security.loginPage.loginSelectorErrorMessage', { + defaultMessage: 'Could not perform login.', + }), + }); + + this.setState({ loadingState: { type: LoadingStateType.None } }); + } + }; + + private isLoadingState(type: LoadingStateType.None | LoadingStateType.Form): boolean; + private isLoadingState(type: LoadingStateType.Selector, providerName: string): boolean; + private isLoadingState(type: LoadingStateType, providerName?: string) { + const { loadingState } = this.state; + if (loadingState.type !== type) { + return false; + } + + return ( + loadingState.type !== LoadingStateType.Selector || loadingState.providerName === providerName + ); + } +} diff --git a/x-pack/plugins/security/public/authentication/login/login_app.test.ts b/x-pack/plugins/security/public/authentication/login/login_app.test.ts index 051f08058ed8d..2597a935f45df 100644 --- a/x-pack/plugins/security/public/authentication/login/login_app.test.ts +++ b/x-pack/plugins/security/public/authentication/login/login_app.test.ts @@ -38,7 +38,6 @@ describe('loginApp', () => { it('properly renders application', async () => { const coreSetupMock = coreMock.createSetup(); const coreStartMock = coreMock.createStart(); - coreStartMock.injectedMetadata.getInjectedVar.mockReturnValue(true); coreSetupMock.getStartServices.mockResolvedValue([coreStartMock, {}]); const containerMock = document.createElement('div'); @@ -55,16 +54,13 @@ describe('loginApp', () => { history: (scopedHistoryMock.create() as unknown) as ScopedHistory, }); - expect(coreStartMock.injectedMetadata.getInjectedVar).toHaveBeenCalledTimes(1); - expect(coreStartMock.injectedMetadata.getInjectedVar).toHaveBeenCalledWith('secureCookies'); - const mockRenderApp = jest.requireMock('./login_page').renderLoginPage; expect(mockRenderApp).toHaveBeenCalledTimes(1); expect(mockRenderApp).toHaveBeenCalledWith(coreStartMock.i18n, containerMock, { http: coreStartMock.http, + notifications: coreStartMock.notifications, fatalErrors: coreStartMock.fatalErrors, loginAssistanceMessage: 'some-message', - requiresSecureConnection: true, }); }); }); diff --git a/x-pack/plugins/security/public/authentication/login/login_app.ts b/x-pack/plugins/security/public/authentication/login/login_app.ts index 4f4bf3903a1fa..1642aba51c1ae 100644 --- a/x-pack/plugins/security/public/authentication/login/login_app.ts +++ b/x-pack/plugins/security/public/authentication/login/login_app.ts @@ -31,11 +31,9 @@ export const loginApp = Object.freeze({ ]); return renderLoginPage(coreStart.i18n, element, { http: coreStart.http, + notifications: coreStart.notifications, fatalErrors: coreStart.fatalErrors, loginAssistanceMessage: config.loginAssistanceMessage, - requiresSecureConnection: coreStart.injectedMetadata.getInjectedVar( - 'secureCookies' - ) as boolean, }); }, }); diff --git a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx index 294434cd08ebc..c4be57d8d7db7 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx @@ -8,15 +8,18 @@ import React from 'react'; import { shallow } from 'enzyme'; import { act } from '@testing-library/react'; import { nextTick } from 'test_utils/enzyme_helpers'; -import { LoginState } from './login_state'; +import { LoginState } from '../../../common/login_state'; import { LoginPage } from './login_page'; import { coreMock } from '../../../../../../src/core/public/mocks'; -import { DisabledLoginForm, BasicLoginForm } from './components'; +import { DisabledLoginForm, LoginForm } from './components'; const createLoginState = (options?: Partial) => { return { allowLogin: true, layout: 'form', + requiresSecureConnection: false, + showLoginForm: true, + selector: { enabled: false, providers: [] }, ...options, } as LoginState; }; @@ -55,9 +58,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -74,14 +77,14 @@ describe('LoginPage', () => { describe('disabled form states', () => { it('renders as expected when secure connection is required but not present', async () => { const coreStartMock = coreMock.createStart(); - httpMock.get.mockResolvedValue(createLoginState()); + httpMock.get.mockResolvedValue(createLoginState({ requiresSecureConnection: true })); const wrapper = shallow( ); @@ -100,9 +103,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -121,9 +124,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -144,9 +147,30 @@ describe('LoginPage', () => { const wrapper = shallow( + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(DisabledLoginForm)).toMatchSnapshot(); + }); + + it('renders as expected when login is not enabled', async () => { + const coreStartMock = coreMock.createStart(); + httpMock.get.mockResolvedValue(createLoginState({ showLoginForm: false })); + + const wrapper = shallow( + ); @@ -167,9 +191,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -179,7 +203,7 @@ describe('LoginPage', () => { resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot }); - expect(wrapper.find(BasicLoginForm)).toMatchSnapshot(); + expect(wrapper.find(LoginForm)).toMatchSnapshot(); }); it('renders as expected when info message is set', async () => { @@ -190,9 +214,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -202,7 +226,7 @@ describe('LoginPage', () => { resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot }); - expect(wrapper.find(BasicLoginForm)).toMatchSnapshot(); + expect(wrapper.find(LoginForm)).toMatchSnapshot(); }); it('renders as expected when loginAssistanceMessage is set', async () => { @@ -212,9 +236,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -224,7 +248,7 @@ describe('LoginPage', () => { resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot }); - expect(wrapper.find(BasicLoginForm)).toMatchSnapshot(); + expect(wrapper.find(LoginForm)).toMatchSnapshot(); }); }); @@ -236,9 +260,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -261,9 +285,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); diff --git a/x-pack/plugins/security/public/authentication/login/login_page.tsx b/x-pack/plugins/security/public/authentication/login/login_page.tsx index d38dee74faedf..70f8f76ee0a9c 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.tsx @@ -12,16 +12,15 @@ import { parse } from 'url'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { CoreStart, FatalErrorsStart, HttpStart } from 'src/core/public'; -import { LoginLayout } from '../../../common/licensing'; -import { BasicLoginForm, DisabledLoginForm } from './components'; -import { LoginState } from './login_state'; +import { CoreStart, FatalErrorsStart, HttpStart, NotificationsStart } from 'src/core/public'; +import { LoginState } from '../../../common/login_state'; +import { LoginForm, DisabledLoginForm } from './components'; interface Props { http: HttpStart; + notifications: NotificationsStart; fatalErrors: FatalErrorsStart; loginAssistanceMessage: string; - requiresSecureConnection: boolean; } interface State { @@ -44,7 +43,7 @@ const infoMessageMap = new Map([ ]); export class LoginPage extends Component { - state = { loginState: null }; + state = { loginState: null } as State; public async componentDidMount() { const loadingCount$ = new BehaviorSubject(1); @@ -67,12 +66,10 @@ export class LoginPage extends Component { } const isSecureConnection = !!window.location.protocol.match(/^https/); - const { allowLogin, layout } = loginState; + const { allowLogin, layout, requiresSecureConnection } = loginState; const loginIsSupported = - this.props.requiresSecureConnection && !isSecureConnection - ? false - : allowLogin && layout === 'form'; + requiresSecureConnection && !isSecureConnection ? false : allowLogin && layout === 'form'; const contentHeaderClasses = classNames('loginWelcome__content', 'eui-textCenter', { ['loginWelcome__contentDisabledForm']: !loginIsSupported, @@ -111,7 +108,7 @@ export class LoginPage extends Component {
- {this.getLoginForm({ isSecureConnection, layout })} + {this.getLoginForm({ ...loginState, isSecureConnection })}
@@ -119,13 +116,34 @@ export class LoginPage extends Component { } private getLoginForm = ({ - isSecureConnection, layout, - }: { - isSecureConnection: boolean; - layout: LoginLayout; - }) => { - if (this.props.requiresSecureConnection && !isSecureConnection) { + requiresSecureConnection, + isSecureConnection, + selector, + showLoginForm, + }: LoginState & { isSecureConnection: boolean }) => { + const isLoginExplicitlyDisabled = + !showLoginForm && (!selector.enabled || selector.providers.length === 0); + if (isLoginExplicitlyDisabled) { + return ( + + } + message={ + + } + /> + ); + } + + if (requiresSecureConnection && !isSecureConnection) { return ( { ); } - switch (layout) { - case 'form': - return ( - - ); - case 'error-es-unavailable': - return ( - - } - message={ - - } - /> - ); - case 'error-xpack-unavailable': - return ( - - } - message={ - - } - /> - ); - default: - return ( - - } - message={ - - } - /> - ); + if (layout === 'error-es-unavailable') { + return ( + + } + message={ + + } + /> + ); + } + + if (layout === 'error-xpack-unavailable') { + return ( + + } + message={ + + } + /> + ); + } + + if (layout !== 'form') { + return ( + + } + message={ + + } + /> + ); } + + return ( + + ); }; } diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index af019ff10dedc..a595b63faaf9b 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -5,6 +5,7 @@ */ jest.mock('./providers/basic'); +jest.mock('./providers/token'); jest.mock('./providers/saml'); jest.mock('./providers/http'); @@ -20,33 +21,32 @@ import { sessionStorageMock, } from '../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { ConfigSchema, createConfig } from '../config'; import { AuthenticationResult } from './authentication_result'; import { Authenticator, AuthenticatorOptions, ProviderSession } from './authenticator'; import { DeauthenticationResult } from './deauthentication_result'; -import { BasicAuthenticationProvider } from './providers'; +import { BasicAuthenticationProvider, SAMLAuthenticationProvider } from './providers'; function getMockOptions({ session, providers, http = {}, + selector, }: { session?: AuthenticatorOptions['config']['session']; - providers?: string[]; + providers?: Record | string[]; http?: Partial; + selector?: AuthenticatorOptions['config']['authc']['selector']; } = {}) { return { clusterClient: elasticsearchServiceMock.createClusterClient(), basePath: httpServiceMock.createSetupContract().basePath, loggers: loggingServiceMock.create(), - config: { - session: { idleTimeout: null, lifespan: null, ...(session || {}) }, - authc: { - providers: providers || [], - oidc: {}, - saml: {}, - http: { enabled: true, autoSchemesEnabled: true, schemes: ['apikey'], ...http }, - }, - }, + config: createConfig( + ConfigSchema.validate({ session, authc: { selector, providers, http } }), + loggingServiceMock.create().get(), + { isTLSEnabled: false } + ), sessionStorageFactory: sessionStorageMock.createFactory(), }; } @@ -56,26 +56,36 @@ describe('Authenticator', () => { beforeEach(() => { mockBasicAuthenticationProvider = { login: jest.fn(), - authenticate: jest.fn(), - logout: jest.fn(), + authenticate: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()), + logout: jest.fn().mockResolvedValue(DeauthenticationResult.notHandled()), getHTTPAuthenticationScheme: jest.fn(), }; jest.requireMock('./providers/http').HTTPAuthenticationProvider.mockImplementation(() => ({ + type: 'http', authenticate: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()), + logout: jest.fn().mockResolvedValue(DeauthenticationResult.notHandled()), + })); + + jest.requireMock('./providers/basic').BasicAuthenticationProvider.mockImplementation(() => ({ + type: 'basic', + ...mockBasicAuthenticationProvider, })); - jest - .requireMock('./providers/basic') - .BasicAuthenticationProvider.mockImplementation(() => mockBasicAuthenticationProvider); + jest.requireMock('./providers/saml').SAMLAuthenticationProvider.mockImplementation(() => ({ + type: 'saml', + getHTTPAuthenticationScheme: jest.fn(), + })); }); afterEach(() => jest.clearAllMocks()); describe('initialization', () => { it('fails if authentication providers are not configured.', () => { - expect(() => new Authenticator(getMockOptions())).toThrowError( - 'No authentication provider is configured. Verify `xpack.security.authc.providers` config value.' + expect( + () => new Authenticator(getMockOptions({ providers: {}, http: { enabled: false } })) + ).toThrowError( + 'No authentication provider is configured. Verify `xpack.security.authc.*` config value.' ); }); @@ -85,11 +95,19 @@ describe('Authenticator', () => { ); }); + it('fails if any of the user specified provider uses reserved __http__ name.', () => { + expect( + () => + new Authenticator(getMockOptions({ providers: { basic: { __http__: { order: 0 } } } })) + ).toThrowError('Provider name "__http__" is reserved.'); + }); + describe('HTTP authentication provider', () => { beforeEach(() => { jest .requireMock('./providers/basic') .BasicAuthenticationProvider.mockImplementation(() => ({ + type: 'basic', getHTTPAuthenticationScheme: jest.fn().mockReturnValue('basic'), })); }); @@ -97,9 +115,9 @@ describe('Authenticator', () => { afterEach(() => jest.resetAllMocks()); it('enabled by default', () => { - const authenticator = new Authenticator(getMockOptions({ providers: ['basic'] })); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('http')).toBe(true); + const authenticator = new Authenticator(getMockOptions()); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('http')).toBe(true); expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider @@ -110,11 +128,13 @@ describe('Authenticator', () => { it('includes all required schemes if `autoSchemesEnabled` is enabled', () => { const authenticator = new Authenticator( - getMockOptions({ providers: ['basic', 'kerberos'] }) + getMockOptions({ + providers: { basic: { basic1: { order: 0 } }, kerberos: { kerberos1: { order: 1 } } }, + }) ); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('kerberos')).toBe(true); - expect(authenticator.isProviderEnabled('http')).toBe(true); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('kerberos')).toBe(true); + expect(authenticator.isProviderTypeEnabled('http')).toBe(true); expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider @@ -125,11 +145,14 @@ describe('Authenticator', () => { it('does not include additional schemes if `autoSchemesEnabled` is disabled', () => { const authenticator = new Authenticator( - getMockOptions({ providers: ['basic', 'kerberos'], http: { autoSchemesEnabled: false } }) + getMockOptions({ + providers: { basic: { basic1: { order: 0 } }, kerberos: { kerberos1: { order: 1 } } }, + http: { autoSchemesEnabled: false }, + }) ); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('kerberos')).toBe(true); - expect(authenticator.isProviderEnabled('http')).toBe(true); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('kerberos')).toBe(true); + expect(authenticator.isProviderTypeEnabled('http')).toBe(true); expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider @@ -138,10 +161,13 @@ describe('Authenticator', () => { it('disabled if explicitly disabled', () => { const authenticator = new Authenticator( - getMockOptions({ providers: ['basic'], http: { enabled: false } }) + getMockOptions({ + providers: { basic: { basic1: { order: 0 } } }, + http: { enabled: false }, + }) ); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('http')).toBe(false); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('http')).toBe(false); expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider @@ -156,14 +182,15 @@ describe('Authenticator', () => { let mockSessionStorage: jest.Mocked>; let mockSessVal: any; beforeEach(() => { - mockOptions = getMockOptions({ providers: ['basic'] }); + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue(null); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); mockSessVal = { idleTimeoutExpiration: null, lifespanExpiration: null, state: { authorization: 'Basic xxx' }, - provider: 'basic', + provider: { type: 'basic', name: 'basic1' }, path: mockOptions.basePath.serverBasePath, }; @@ -176,17 +203,26 @@ describe('Authenticator', () => { ); }); - it('fails if login attempt is not provided.', async () => { + it('fails if login attempt is not provided or invalid.', async () => { await expect( authenticator.login(httpServerMock.createKibanaRequest(), undefined as any) ).rejects.toThrowError( - 'Login attempt should be an object with non-empty "provider" property.' + 'Login attempt should be an object with non-empty "provider.type" or "provider.name" property.' ); await expect( authenticator.login(httpServerMock.createKibanaRequest(), {} as any) ).rejects.toThrowError( - 'Login attempt should be an object with non-empty "provider" property.' + 'Login attempt should be an object with non-empty "provider.type" or "provider.name" property.' + ); + + await expect( + authenticator.login(httpServerMock.createKibanaRequest(), { + provider: 'basic', + value: {}, + } as any) + ).rejects.toThrowError( + 'Login attempt should be an object with non-empty "provider.type" or "provider.name" property.' ); }); @@ -198,9 +234,9 @@ describe('Authenticator', () => { AuthenticationResult.failed(failureReason) ); - await expect(authenticator.login(request, { provider: 'basic', value: {} })).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.failed(failureReason)); }); it('returns user that authentication provider returns.', async () => { @@ -211,7 +247,9 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } }) ); - await expect(authenticator.login(request, { provider: 'basic', value: {} })).resolves.toEqual( + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } }) ); }); @@ -225,9 +263,9 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { state: { authorization } }) ); - await expect(authenticator.login(request, { provider: 'basic', value: {} })).resolves.toEqual( - AuthenticationResult.succeeded(user, { state: { authorization } }) - ); + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(user, { state: { authorization } })); expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ @@ -238,9 +276,171 @@ describe('Authenticator', () => { it('returns `notHandled` if login attempt is targeted to not configured provider.', async () => { const request = httpServerMock.createKibanaRequest(); - await expect(authenticator.login(request, { provider: 'token', value: {} })).resolves.toEqual( - AuthenticationResult.notHandled() - ); + await expect( + authenticator.login(request, { provider: { type: 'token' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + + await expect( + authenticator.login(request, { provider: { name: 'basic2' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + }); + + describe('multi-provider scenarios', () => { + let mockSAMLAuthenticationProvider1: jest.Mocked>; + let mockSAMLAuthenticationProvider2: jest.Mocked>; + + beforeEach(() => { + mockSAMLAuthenticationProvider1 = { + login: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()), + authenticate: jest.fn(), + logout: jest.fn(), + getHTTPAuthenticationScheme: jest.fn(), + }; + + mockSAMLAuthenticationProvider2 = { + login: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()), + authenticate: jest.fn(), + logout: jest.fn(), + getHTTPAuthenticationScheme: jest.fn(), + }; + + jest + .requireMock('./providers/saml') + .SAMLAuthenticationProvider.mockImplementationOnce(() => ({ + type: 'saml', + ...mockSAMLAuthenticationProvider1, + })) + .mockImplementationOnce(() => ({ + type: 'saml', + ...mockSAMLAuthenticationProvider2, + })); + + mockOptions = getMockOptions({ + providers: { + basic: { basic1: { order: 0 } }, + saml: { + saml1: { realm: 'saml1-realm', order: 1 }, + saml2: { realm: 'saml2-realm', order: 2 }, + }, + }, + }); + mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue(null); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + + authenticator = new Authenticator(mockOptions); + }); + + it('tries to login only with the provider that has specified name', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + + mockSAMLAuthenticationProvider2.login.mockResolvedValue( + AuthenticationResult.succeeded(user, { state: { token: 'access-token' } }) + ); + + await expect( + authenticator.login(request, { provider: { name: 'saml2' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.succeeded(user, { state: { token: 'access-token' } }) + ); + + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + ...mockSessVal, + provider: { type: 'saml', name: 'saml2' }, + state: { token: 'access-token' }, + }); + + expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled(); + expect(mockSAMLAuthenticationProvider1.login).not.toHaveBeenCalled(); + }); + + it('tries to login only with the provider that has specified type', async () => { + const request = httpServerMock.createKibanaRequest(); + + await expect( + authenticator.login(request, { provider: { type: 'saml' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + + expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled(); + expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledTimes(1); + expect(mockSAMLAuthenticationProvider2.login).toHaveBeenCalledTimes(1); + expect(mockSAMLAuthenticationProvider1.login.mock.invocationCallOrder[0]).toBeLessThan( + mockSAMLAuthenticationProvider2.login.mock.invocationCallOrder[0] + ); + }); + + it('returns as soon as provider handles request', async () => { + const request = httpServerMock.createKibanaRequest(); + + const authenticationResults = [ + AuthenticationResult.failed(new Error('Fail')), + AuthenticationResult.succeeded(mockAuthenticatedUser(), { state: { result: '200' } }), + AuthenticationResult.redirectTo('/some/url', { state: { result: '302' } }), + ]; + + for (const result of authenticationResults) { + mockSAMLAuthenticationProvider1.login.mockResolvedValue(result); + + await expect( + authenticator.login(request, { provider: { type: 'saml' }, value: {} }) + ).resolves.toEqual(result); + } + + expect(mockSessionStorage.set).toHaveBeenCalledTimes(2); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + ...mockSessVal, + provider: { type: 'saml', name: 'saml1' }, + state: { result: '200' }, + }); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + ...mockSessVal, + provider: { type: 'saml', name: 'saml1' }, + state: { result: '302' }, + }); + + expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled(); + expect(mockSAMLAuthenticationProvider2.login).not.toHaveBeenCalled(); + expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledTimes(3); + }); + + it('provides session only if provider name matches', async () => { + const request = httpServerMock.createKibanaRequest(); + + mockSessionStorage.get.mockResolvedValue({ + ...mockSessVal, + provider: { type: 'saml', name: 'saml2' }, + }); + + const loginAttemptValue = Symbol('attempt'); + await expect( + authenticator.login(request, { provider: { type: 'saml' }, value: loginAttemptValue }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + + expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled(); + + expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledTimes(1); + expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledWith( + request, + loginAttemptValue, + null + ); + + expect(mockSAMLAuthenticationProvider2.login).toHaveBeenCalledTimes(1); + expect(mockSAMLAuthenticationProvider2.login).toHaveBeenCalledWith( + request, + loginAttemptValue, + mockSessVal.state + ); + + // Presence of the session has precedence over order. + expect(mockSAMLAuthenticationProvider2.login.mock.invocationCallOrder[0]).toBeLessThan( + mockSAMLAuthenticationProvider1.login.mock.invocationCallOrder[0] + ); + }); }); it('clears session if it belongs to a different provider.', async () => { @@ -249,10 +449,13 @@ describe('Authenticator', () => { const request = httpServerMock.createKibanaRequest(); mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'token' }); + mockSessionStorage.get.mockResolvedValue({ + ...mockSessVal, + provider: { type: 'token', name: 'token1' }, + }); await expect( - authenticator.login(request, { provider: 'basic', value: credentials }) + authenticator.login(request, { provider: { type: 'basic' }, value: credentials }) ).resolves.toEqual(AuthenticationResult.succeeded(user)); expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledWith( @@ -265,17 +468,67 @@ describe('Authenticator', () => { expect(mockSessionStorage.clear).toHaveBeenCalled(); }); + it('clears session if it belongs to a provider with the name that is registered but has different type.', async () => { + const user = mockAuthenticatedUser(); + const credentials = { username: 'user', password: 'password' }; + const request = httpServerMock.createKibanaRequest(); + + // Re-configure authenticator with `token` provider that uses the name of `basic`. + const loginMock = jest.fn().mockResolvedValue(AuthenticationResult.succeeded(user)); + jest.requireMock('./providers/token').TokenAuthenticationProvider.mockImplementation(() => ({ + type: 'token', + login: loginMock, + getHTTPAuthenticationScheme: jest.fn(), + })); + mockOptions = getMockOptions({ providers: { token: { basic1: { order: 0 } } } }); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + authenticator = new Authenticator(mockOptions); + + mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); + mockSessionStorage.get.mockResolvedValue(mockSessVal); + + await expect( + authenticator.login(request, { provider: { name: 'basic1' }, value: credentials }) + ).resolves.toEqual(AuthenticationResult.succeeded(user)); + + expect(loginMock).toHaveBeenCalledWith(request, credentials, null); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + }); + it('clears session if provider asked to do so.', async () => { const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest(); + mockSessionStorage.get.mockResolvedValue(mockSessVal); mockBasicAuthenticationProvider.login.mockResolvedValue( AuthenticationResult.succeeded(user, { state: null }) ); - await expect(authenticator.login(request, { provider: 'basic', value: {} })).resolves.toEqual( - AuthenticationResult.succeeded(user, { state: null }) - ); + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(user, { state: null })); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + }); + + it('clears legacy session.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + + // Use string format for the `provider` session value field to emulate legacy session. + mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'basic' }); + + mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(user)); + + expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledTimes(1); + expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledWith(request, {}, null); expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).toHaveBeenCalled(); @@ -288,14 +541,15 @@ describe('Authenticator', () => { let mockSessionStorage: jest.Mocked>; let mockSessVal: any; beforeEach(() => { - mockOptions = getMockOptions({ providers: ['basic'] }); + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue(null); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); mockSessVal = { idleTimeoutExpiration: null, lifespanExpiration: null, state: { authorization: 'Basic xxx' }, - provider: 'basic', + provider: { type: 'basic', name: 'basic1' }, path: mockOptions.basePath.serverBasePath, }; @@ -430,7 +684,7 @@ describe('Authenticator', () => { idleTimeout: duration(3600 * 24), lifespan: null, }, - providers: ['basic'], + providers: { basic: { basic1: { order: 0 } } }, }); mockSessionStorage = sessionStorageMock.create(); @@ -469,7 +723,7 @@ describe('Authenticator', () => { idleTimeout: duration(hr * 2), lifespan: duration(hr * 8), }, - providers: ['basic'], + providers: { basic: { basic1: { order: 0 } } }, }); mockSessionStorage = sessionStorageMock.create(); @@ -521,7 +775,7 @@ describe('Authenticator', () => { idleTimeout: null, lifespan, }, - providers: ['basic'], + providers: { basic: { basic1: { order: 0 } } }, }); mockSessionStorage = sessionStorageMock.create(); @@ -703,14 +957,33 @@ describe('Authenticator', () => { expect(mockSessionStorage.clear).toHaveBeenCalled(); }); + it('clears legacy session.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + + // Use string format for the `provider` session value field to emulate legacy session. + mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'basic' }); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user) + ); + + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledTimes(1); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledWith(request, null); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + }); + it('does not clear session if provider can not handle system API request authentication with active session.', async () => { const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-system-request': 'true' }, }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.notHandled() - ); mockSessionStorage.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( @@ -726,9 +999,6 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'false' }, }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.notHandled() - ); mockSessionStorage.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( @@ -744,10 +1014,10 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'true' }, }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.notHandled() - ); - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'token' }); + mockSessionStorage.get.mockResolvedValue({ + ...mockSessVal, + provider: { type: 'token', name: 'token1' }, + }); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.notHandled() @@ -762,10 +1032,10 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'false' }, }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.notHandled() - ); - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'token' }); + mockSessionStorage.get.mockResolvedValue({ + ...mockSessVal, + provider: { type: 'token', name: 'token1' }, + }); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.notHandled() @@ -774,6 +1044,70 @@ describe('Authenticator', () => { expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).toHaveBeenCalled(); }); + + describe('with Login Selector', () => { + beforeEach(() => { + mockOptions = getMockOptions({ + selector: { enabled: true }, + providers: { basic: { basic1: { order: 0 } } }, + }); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + + authenticator = new Authenticator(mockOptions); + }); + + it('does not redirect to Login Selector if there is an active session', async () => { + const request = httpServerMock.createKibanaRequest(); + mockSessionStorage.get.mockResolvedValue(mockSessVal); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalled(); + }); + + it('does not redirect AJAX requests to Login Selector', async () => { + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalled(); + }); + + it('does not redirect to Login Selector if request has `Authorization` header', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Basic ***' }, + }); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalled(); + }); + + it('does not redirect to Login Selector if it is not enabled', async () => { + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + authenticator = new Authenticator(mockOptions); + + const request = httpServerMock.createKibanaRequest(); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalled(); + }); + + it('redirects to the Login Selector when needed.', async () => { + const request = httpServerMock.createKibanaRequest(); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/login?next=%2Fmock-server-basepath%2Fpath' + ) + ); + expect(mockBasicAuthenticationProvider.authenticate).not.toHaveBeenCalled(); + }); + }); }); describe('`logout` method', () => { @@ -782,14 +1116,14 @@ describe('Authenticator', () => { let mockSessionStorage: jest.Mocked>; let mockSessVal: any; beforeEach(() => { - mockOptions = getMockOptions({ providers: ['basic'] }); + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); mockSessionStorage = sessionStorageMock.create(); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); mockSessVal = { idleTimeoutExpiration: null, lifespanExpiration: null, state: { authorization: 'Basic xxx' }, - provider: 'basic', + provider: { type: 'basic', name: 'basic1' }, path: mockOptions.basePath.serverBasePath, }; @@ -805,6 +1139,7 @@ describe('Authenticator', () => { it('returns `notHandled` if session does not exist.', async () => { const request = httpServerMock.createKibanaRequest(); mockSessionStorage.get.mockResolvedValue(null); + mockBasicAuthenticationProvider.logout.mockResolvedValue(DeauthenticationResult.notHandled()); await expect(authenticator.logout(request)).resolves.toEqual( DeauthenticationResult.notHandled() @@ -829,7 +1164,7 @@ describe('Authenticator', () => { }); it('if session does not exist but provider name is valid, returns whatever authentication provider returns.', async () => { - const request = httpServerMock.createKibanaRequest({ query: { provider: 'basic' } }); + const request = httpServerMock.createKibanaRequest({ query: { provider: 'basic1' } }); mockSessionStorage.get.mockResolvedValue(null); mockBasicAuthenticationProvider.logout.mockResolvedValue( @@ -855,16 +1190,20 @@ describe('Authenticator', () => { expect(mockSessionStorage.clear).not.toHaveBeenCalled(); }); - it('only clears session if it belongs to not configured provider.', async () => { + it('clears session if it belongs to not configured provider.', async () => { const request = httpServerMock.createKibanaRequest(); const state = { authorization: 'Bearer xxx' }; - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, state, provider: 'token' }); + mockSessionStorage.get.mockResolvedValue({ + ...mockSessVal, + state, + provider: { type: 'token', name: 'token1' }, + }); await expect(authenticator.logout(request)).resolves.toEqual( DeauthenticationResult.notHandled() ); - expect(mockBasicAuthenticationProvider.logout).not.toHaveBeenCalled(); + expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); expect(mockSessionStorage.clear).toHaveBeenCalled(); }); }); @@ -874,7 +1213,7 @@ describe('Authenticator', () => { let mockOptions: ReturnType; let mockSessionStorage: jest.Mocked>; beforeEach(() => { - mockOptions = getMockOptions({ providers: ['basic'] }); + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); mockSessionStorage = sessionStorageMock.create(); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); @@ -889,13 +1228,13 @@ describe('Authenticator', () => { now: currentDate, idleTimeoutExpiration: currentDate + 60000, lifespanExpiration: currentDate + 120000, - provider: 'basic', + provider: 'basic1', }; mockSessionStorage.get.mockResolvedValue({ idleTimeoutExpiration: mockInfo.idleTimeoutExpiration, lifespanExpiration: mockInfo.lifespanExpiration, state, - provider: mockInfo.provider, + provider: { type: 'basic', name: mockInfo.provider }, path: mockOptions.basePath.serverBasePath, }); jest.spyOn(Date, 'now').mockImplementation(() => currentDate); @@ -917,13 +1256,22 @@ describe('Authenticator', () => { describe('`isProviderEnabled` method', () => { it('returns `true` only if specified provider is enabled', () => { - let authenticator = new Authenticator(getMockOptions({ providers: ['basic'] })); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('saml')).toBe(false); - - authenticator = new Authenticator(getMockOptions({ providers: ['basic', 'saml'] })); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('saml')).toBe(true); + let authenticator = new Authenticator( + getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }) + ); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('saml')).toBe(false); + + authenticator = new Authenticator( + getMockOptions({ + providers: { + basic: { basic1: { order: 0 } }, + saml: { saml1: { order: 1, realm: 'test' } }, + }, + }) + ); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('saml')).toBe(true); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index e2e2d12917394..caf5b485d05e3 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -28,21 +28,22 @@ import { OIDCAuthenticationProvider, PKIAuthenticationProvider, HTTPAuthenticationProvider, - isSAMLRequestQuery, } from './providers'; import { AuthenticationResult } from './authentication_result'; import { DeauthenticationResult } from './deauthentication_result'; import { Tokens } from './tokens'; import { SessionInfo } from '../../public'; +import { canRedirectRequest } from './can_redirect_request'; +import { HTTPAuthorizationHeader } from './http_authentication'; /** * The shape of the session that is actually stored in the cookie. */ export interface ProviderSession { /** - * Name/type of the provider this session belongs to. + * Name and type of the provider this session belongs to. */ - provider: string; + provider: { type: string; name: string }; /** * The Unix time in ms when the session should be considered expired. If `null`, session will stay @@ -73,9 +74,9 @@ export interface ProviderSession { */ export interface ProviderLoginAttempt { /** - * Name/type of the provider this login attempt is targeted for. + * Name or type of the provider this login attempt is targeted for. */ - provider: string; + provider: { name: string } | { type: string }; /** * Login attempt can have any form and defined by the specific provider. @@ -115,11 +116,42 @@ function assertRequest(request: KibanaRequest) { } function assertLoginAttempt(attempt: ProviderLoginAttempt) { - if (!attempt || !attempt.provider || typeof attempt.provider !== 'string') { - throw new Error('Login attempt should be an object with non-empty "provider" property.'); + if (!isLoginAttemptWithProviderType(attempt) && !isLoginAttemptWithProviderName(attempt)) { + throw new Error( + 'Login attempt should be an object with non-empty "provider.type" or "provider.name" property.' + ); } } +function isLoginAttemptWithProviderName( + attempt: unknown +): attempt is { value: unknown; provider: { name: string } } { + return ( + typeof attempt === 'object' && + (attempt as any)?.provider?.name && + typeof (attempt as any)?.provider?.name === 'string' + ); +} + +function isLoginAttemptWithProviderType( + attempt: unknown +): attempt is { value: unknown; provider: { type: string } } { + return ( + typeof attempt === 'object' && + (attempt as any)?.provider?.type && + typeof (attempt as any)?.provider?.type === 'string' + ); +} + +/** + * Determines if session value was created by the previous Kibana versions which had a different + * session value format. + * @param sessionValue The session value to check. + */ +function isLegacyProviderSession(sessionValue: any) { + return typeof sessionValue?.provider === 'string'; +} + /** * Instantiates authentication provider based on the provider key from config. * @param providerType Provider type key. @@ -194,29 +226,22 @@ export class Authenticator { }), }; - const authProviders = this.options.config.authc.providers; - if (authProviders.length === 0) { - throw new Error( - 'No authentication provider is configured. Verify `xpack.security.authc.providers` config value.' - ); - } - this.providers = new Map( - authProviders.map(providerType => { - const providerSpecificOptions = this.options.config.authc.hasOwnProperty(providerType) - ? (this.options.config.authc as Record)[providerType] - : undefined; - - this.logger.debug(`Enabling "${providerType}" authentication provider.`); + this.options.config.authc.sortedProviders.map(({ type, name }) => { + this.logger.debug(`Enabling "${name}" (${type}) authentication provider.`); return [ - providerType, + name, instantiateProvider( - providerType, - Object.freeze({ ...providerCommonOptions, logger: options.loggers.get(providerType) }), - providerSpecificOptions + type, + Object.freeze({ + ...providerCommonOptions, + name, + logger: options.loggers.get(type, name), + }), + this.options.config.authc.providers[type]?.[name] ), - ] as [string, BaseAuthenticationProvider]; + ]; }) ); @@ -225,11 +250,18 @@ export class Authenticator { this.setupHTTPAuthenticationProvider( Object.freeze({ ...providerCommonOptions, + name: '__http__', logger: options.loggers.get(HTTPAuthenticationProvider.type), }) ); } + if (this.providers.size === 0) { + throw new Error( + 'No authentication provider is configured. Verify `xpack.security.authc.*` config value.' + ); + } + this.serverBasePath = this.options.basePath.serverBasePath || '/'; this.idleTimeout = this.options.config.session.idleTimeout; @@ -245,60 +277,58 @@ export class Authenticator { assertRequest(request); assertLoginAttempt(attempt); - // If there is an attempt to login with a provider that isn't enabled, we should fail. - const provider = this.providers.get(attempt.provider); - if (provider === undefined) { + const sessionStorage = this.options.sessionStorageFactory.asScoped(request); + const existingSession = await this.getSessionValue(sessionStorage); + + // Login attempt can target specific provider by its name (e.g. chosen at the Login Selector UI) + // or a group of providers with the specified type (e.g. in case of 3rd-party initiated login + // attempts we may not know what provider exactly can handle that attempt and we have to try + // every enabled provider of the specified type). + const providers: Array<[string, BaseAuthenticationProvider]> = + isLoginAttemptWithProviderName(attempt) && this.providers.has(attempt.provider.name) + ? [[attempt.provider.name, this.providers.get(attempt.provider.name)!]] + : isLoginAttemptWithProviderType(attempt) + ? [...this.providerIterator(existingSession)].filter( + ([, { type }]) => type === attempt.provider.type + ) + : []; + + if (providers.length === 0) { this.logger.debug( - `Login attempt for provider "${attempt.provider}" is detected, but it isn't enabled.` + `Login attempt for provider with ${ + isLoginAttemptWithProviderName(attempt) + ? `name ${attempt.provider.name}` + : `type "${(attempt.provider as Record).type}"` + } is detected, but it isn't enabled.` ); return AuthenticationResult.notHandled(); } - this.logger.debug(`Performing login using "${attempt.provider}" provider.`); - - const sessionStorage = this.options.sessionStorageFactory.asScoped(request); + for (const [providerName, provider] of providers) { + // Check if current session has been set by this provider. + const ownsSession = + existingSession?.provider.name === providerName && + existingSession?.provider.type === provider.type; - // If we detect an existing session that belongs to a different provider than the one requested - // to perform a login we should clear such session. - let existingSession = await this.getSessionValue(sessionStorage); - if (existingSession && existingSession.provider !== attempt.provider) { - this.logger.debug( - `Clearing existing session of another ("${existingSession.provider}") provider.` + const authenticationResult = await provider.login( + request, + attempt.value, + ownsSession ? existingSession!.state : null ); - sessionStorage.clear(); - existingSession = null; - } - - const authenticationResult = await provider.login( - request, - attempt.value, - existingSession && existingSession.state - ); - // There are two possible cases when we'd want to clear existing state: - // 1. If provider owned the state (e.g. intermediate state used for multi step login), but failed - // to login, that likely means that state is not valid anymore and we should clear it. - // 2. Also provider can specifically ask to clear state by setting it to `null` even if - // authentication attempt didn't fail (e.g. custom realm could "pin" client/request identity to - // a server-side only session established during multi step login that relied on intermediate - // client-side state which isn't needed anymore). - const shouldClearSession = - authenticationResult.shouldClearState() || - (authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401); - if (existingSession && shouldClearSession) { - sessionStorage.clear(); - } else if (authenticationResult.shouldUpdateState()) { - const { idleTimeoutExpiration, lifespanExpiration } = this.calculateExpiry(existingSession); - sessionStorage.set({ - state: authenticationResult.state, - provider: attempt.provider, - idleTimeoutExpiration, - lifespanExpiration, - path: this.serverBasePath, + this.updateSessionValue(sessionStorage, { + provider: { type: provider.type, name: providerName }, + isSystemRequest: request.isSystemRequest, + authenticationResult, + existingSession: ownsSession ? existingSession : null, }); + + if (!authenticationResult.notHandled()) { + return authenticationResult; + } } - return authenticationResult; + return AuthenticationResult.notHandled(); } /** @@ -311,33 +341,46 @@ export class Authenticator { const sessionStorage = this.options.sessionStorageFactory.asScoped(request); const existingSession = await this.getSessionValue(sessionStorage); - let authenticationResult = AuthenticationResult.notHandled(); - for (const [providerType, provider] of this.providerIterator(existingSession)) { + // If request doesn't have any session information, isn't attributed with HTTP Authorization + // header and Login Selector is enabled, we must redirect user to the login selector. + const useLoginSelector = + !existingSession && + this.options.config.authc.selector.enabled && + canRedirectRequest(request) && + HTTPAuthorizationHeader.parseFromRequest(request) == null; + if (useLoginSelector) { + this.logger.debug('Redirecting request to Login Selector.'); + return AuthenticationResult.redirectTo( + `${this.options.basePath.serverBasePath}/login?next=${encodeURIComponent( + `${this.options.basePath.get(request)}${request.url.path}` + )}` + ); + } + + for (const [providerName, provider] of this.providerIterator(existingSession)) { // Check if current session has been set by this provider. - const ownsSession = existingSession && existingSession.provider === providerType; + const ownsSession = + existingSession?.provider.name === providerName && + existingSession?.provider.type === provider.type; - authenticationResult = await provider.authenticate( + const authenticationResult = await provider.authenticate( request, ownsSession ? existingSession!.state : null ); this.updateSessionValue(sessionStorage, { - providerType, + provider: { type: provider.type, name: providerName }, isSystemRequest: request.isSystemRequest, authenticationResult, existingSession: ownsSession ? existingSession : null, }); - if ( - authenticationResult.failed() || - authenticationResult.succeeded() || - authenticationResult.redirected() - ) { + if (!authenticationResult.notHandled()) { return authenticationResult; } } - return authenticationResult; + return AuthenticationResult.notHandled(); } /** @@ -349,28 +392,33 @@ export class Authenticator { const sessionStorage = this.options.sessionStorageFactory.asScoped(request); const sessionValue = await this.getSessionValue(sessionStorage); - const providerName = this.getProviderName(request.query); if (sessionValue) { sessionStorage.clear(); - return this.providers.get(sessionValue.provider)!.logout(request, sessionValue.state); - } else if (providerName) { + return this.providers.get(sessionValue.provider.name)!.logout(request, sessionValue.state); + } + + const providerName = this.getProviderName(request.query); + if (providerName) { // provider name is passed in a query param and sourced from the browser's local storage; // hence, we can't assume that this provider exists, so we have to check it const provider = this.providers.get(providerName); if (provider) { return provider.logout(request, null); } - } - - // Normally when there is no active session in Kibana, `logout` method shouldn't do anything - // and user will eventually be redirected to the home page to log in. But if SAML is supported there - // is a special case when logout is initiated by the IdP or another SP, then IdP will request _every_ - // SP associated with the current user session to do the logout. So if Kibana (without active session) - // receives such a request it shouldn't redirect user to the home page, but rather redirect back to IdP - // with correct logout response and only Elasticsearch knows how to do that. - if (isSAMLRequestQuery(request.query) && this.providers.has('saml')) { - return this.providers.get('saml')!.logout(request); + } else { + // In case logout is called and we cannot figure out what provider is supposed to handle it, + // we should iterate through all providers and let them decide if they can perform a logout. + // This can be necessary if some 3rd-party initiates logout. And even if user doesn't have an + // active session already some providers can still properly respond to the 3rd-party logout + // request. For example SAML provider can process logout request encoded in `SAMLRequest` + // query string parameter. + for (const [, provider] of this.providerIterator(null)) { + const deauthenticationResult = await provider.logout(request); + if (!deauthenticationResult.notHandled()) { + return deauthenticationResult; + } + } } return DeauthenticationResult.notHandled(); @@ -393,7 +441,7 @@ export class Authenticator { now: Date.now(), idleTimeoutExpiration: sessionValue.idleTimeoutExpiration, lifespanExpiration: sessionValue.lifespanExpiration, - provider: sessionValue.provider, + provider: sessionValue.provider.name, }; } return null; @@ -403,8 +451,8 @@ export class Authenticator { * Checks whether specified provider type is currently enabled. * @param providerType Type of the provider (`basic`, `saml`, `pki` etc.). */ - isProviderEnabled(providerType: string) { - return this.providers.has(providerType); + isProviderTypeEnabled(providerType: string) { + return [...this.providers.values()].some(provider => provider.type === providerType); } /** @@ -428,10 +476,11 @@ export class Authenticator { } } - this.providers.set( - HTTPAuthenticationProvider.type, - new HTTPAuthenticationProvider(options, { supportedSchemes }) - ); + if (this.providers.has(options.name)) { + throw new Error(`Provider name "${options.name}" is reserved.`); + } + + this.providers.set(options.name, new HTTPAuthenticationProvider(options, { supportedSchemes })); } /** @@ -447,11 +496,11 @@ export class Authenticator { if (!sessionValue) { yield* this.providers; } else { - yield [sessionValue.provider, this.providers.get(sessionValue.provider)!]; + yield [sessionValue.provider.name, this.providers.get(sessionValue.provider.name)!]; - for (const [providerType, provider] of this.providers) { - if (providerType !== sessionValue.provider) { - yield [providerType, provider]; + for (const [providerName, provider] of this.providers) { + if (providerName !== sessionValue.provider.name) { + yield [providerName, provider]; } } } @@ -463,14 +512,19 @@ export class Authenticator { * @param sessionStorage Session storage instance. */ private async getSessionValue(sessionStorage: SessionStorage) { - let sessionValue = await sessionStorage.get(); + const sessionValue = await sessionStorage.get(); - // If for some reason we have a session stored for the provider that is not available - // (e.g. when user was logged in with one provider, but then configuration has changed - // and that provider is no longer available), then we should clear session entirely. - if (sessionValue && !this.providers.has(sessionValue.provider)) { + // If we detect that session is in incompatible format or for some reason we have a session + // stored for the provider that is not available anymore (e.g. when user was logged in with one + // provider, but then configuration has changed and that provider is no longer available), then + // we should clear session entirely. + if ( + sessionValue && + (isLegacyProviderSession(sessionValue) || + this.providers.get(sessionValue.provider.name)?.type !== sessionValue.provider.type) + ) { sessionStorage.clear(); - sessionValue = null; + return null; } return sessionValue; @@ -479,12 +533,12 @@ export class Authenticator { private updateSessionValue( sessionStorage: SessionStorage, { - providerType, + provider, authenticationResult, existingSession, isSystemRequest, }: { - providerType: string; + provider: { type: string; name: string }; authenticationResult: AuthenticationResult; existingSession: ProviderSession | null; isSystemRequest: boolean; @@ -515,7 +569,7 @@ export class Authenticator { state: authenticationResult.shouldUpdateState() ? authenticationResult.state : existingSession!.state, - provider: providerType, + provider, idleTimeoutExpiration, lifespanExpiration, path: this.serverBasePath, diff --git a/x-pack/plugins/security/server/authentication/index.mock.ts b/x-pack/plugins/security/server/authentication/index.mock.ts index 43892753f0d3f..8092c1c81017b 100644 --- a/x-pack/plugins/security/server/authentication/index.mock.ts +++ b/x-pack/plugins/security/server/authentication/index.mock.ts @@ -10,7 +10,7 @@ export const authenticationMock = { create: (): jest.Mocked => ({ login: jest.fn(), logout: jest.fn(), - isProviderEnabled: jest.fn(), + isProviderTypeEnabled: jest.fn(), createAPIKey: jest.fn(), getCurrentUser: jest.fn(), grantAPIKeyAsInternalUser: jest.fn(), diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 21e5f18bc0282..6609f8707976b 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -10,7 +10,6 @@ jest.mock('./api_keys'); jest.mock('./authenticator'); import Boom from 'boom'; -import { first } from 'rxjs/operators'; import { loggingServiceMock, @@ -31,7 +30,7 @@ import { ScopedClusterClient, } from '../../../../../src/core/server'; import { AuthenticatedUser } from '../../common/model'; -import { ConfigType, createConfig$ } from '../config'; +import { ConfigSchema, ConfigType, createConfig } from '../config'; import { AuthenticationResult } from './authentication_result'; import { Authentication, setupAuthentication } from '.'; import { @@ -51,23 +50,18 @@ describe('setupAuthentication()', () => { license: jest.Mocked; }; let mockScopedClusterClient: jest.Mocked>; - beforeEach(async () => { - const mockConfig$ = createConfig$( - coreMock.createPluginInitializerContext({ - encryptionKey: 'ab'.repeat(16), - secureCookies: true, - session: { - idleTimeout: null, - lifespan: null, - }, - cookieName: 'my-sid-cookie', - authc: { providers: ['basic'], http: { enabled: true } }, - }), - true - ); + beforeEach(() => { mockSetupAuthenticationParams = { http: coreMock.createSetup().http, - config: await mockConfig$.pipe(first()).toPromise(), + config: createConfig( + ConfigSchema.validate({ + encryptionKey: 'ab'.repeat(16), + secureCookies: true, + cookieName: 'my-sid-cookie', + }), + loggingServiceMock.create().get(), + { isTLSEnabled: false } + ), clusterClient: elasticsearchServiceMock.createClusterClient(), license: licenseMock.create(), loggers: loggingServiceMock.create(), diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index c5c72853e68e1..30ac84632cb7e 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -21,7 +21,7 @@ export { canRedirectRequest } from './can_redirect_request'; export { Authenticator, ProviderLoginAttempt } from './authenticator'; export { AuthenticationResult } from './authentication_result'; export { DeauthenticationResult } from './deauthentication_result'; -export { OIDCAuthenticationFlow, SAMLLoginStep } from './providers'; +export { OIDCLogin, SAMLLogin } from './providers'; export { CreateAPIKeyResult, InvalidateAPIKeyResult, @@ -169,7 +169,7 @@ export async function setupAuthentication({ login: authenticator.login.bind(authenticator), logout: authenticator.logout.bind(authenticator), getSessionInfo: authenticator.getSessionInfo.bind(authenticator), - isProviderEnabled: authenticator.isProviderEnabled.bind(authenticator), + isProviderTypeEnabled: authenticator.isProviderTypeEnabled.bind(authenticator), getCurrentUser, createAPIKey: (request: KibanaRequest, params: CreateAPIKeyParams) => apiKeys.create(request, params), diff --git a/x-pack/plugins/security/server/authentication/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts index 0781608f8bc4c..1dcd2885f66dc 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.mock.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts @@ -14,7 +14,7 @@ export type MockAuthenticationProviderOptions = ReturnType< typeof mockAuthenticationProviderOptions >; -export function mockAuthenticationProviderOptions() { +export function mockAuthenticationProviderOptions(options?: { name: string }) { const basePath = httpServiceMock.createSetupContract().basePath; basePath.get.mockReturnValue('/base-path'); @@ -23,5 +23,6 @@ export function mockAuthenticationProviderOptions() { logger: loggingServiceMock.create().get(), basePath, tokens: { refresh: jest.fn(), invalidate: jest.fn() }, + name: options?.name ?? 'basic1', }; } diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index 300e59d9ea3da..48a73586a6fed 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -21,6 +21,7 @@ import { Tokens } from '../tokens'; * Represents available provider options. */ export interface AuthenticationProviderOptions { + name: string; basePath: HttpServiceSetup['basePath']; client: IClusterClient; logger: Logger; @@ -41,6 +42,12 @@ export abstract class BaseAuthenticationProvider { */ static readonly type: string; + /** + * Type of the provider. We use `this.constructor` trick to get access to the static `type` field + * of the specific `BaseAuthenticationProvider` subclass. + */ + public readonly type = (this.constructor as any).type as string; + /** * Logger instance bound to a specific provider context. */ @@ -102,9 +109,7 @@ export abstract class BaseAuthenticationProvider { ...(await this.options.client .asScoped({ headers: { ...request.headers, ...authHeaders } }) .callAsCurrentUser('shield.authenticate')), - // We use `this.constructor` trick to get access to the static `type` field of the specific - // `BaseAuthenticationProvider` subclass. - authentication_provider: (this.constructor as any).type, + authentication_provider: this.options.name, } as AuthenticatedUser); } } diff --git a/x-pack/plugins/security/server/authentication/providers/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts index b7bdff0531fc2..97ca4e46d3eb5 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.test.ts @@ -91,6 +91,12 @@ describe('BasicAuthenticationProvider', () => { ).resolves.toEqual(AuthenticationResult.notHandled()); }); + it('does not redirect requests that do not require authentication to the login page.', async () => { + await expect( + provider.authenticate(httpServerMock.createKibanaRequest({ routeAuthRequired: false })) + ).resolves.toEqual(AuthenticationResult.notHandled()); + }); + it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => { await expect( provider.authenticate( @@ -172,8 +178,14 @@ describe('BasicAuthenticationProvider', () => { }); describe('`logout` method', () => { - it('always redirects to the login page.', async () => { + it('does not handle logout if state is not present', async () => { await expect(provider.logout(httpServerMock.createKibanaRequest())).resolves.toEqual( + DeauthenticationResult.notHandled() + ); + }); + + it('always redirects to the login page.', async () => { + await expect(provider.logout(httpServerMock.createKibanaRequest(), {})).resolves.toEqual( DeauthenticationResult.redirectTo('/base-path/login?msg=LOGGED_OUT') ); }); @@ -181,7 +193,10 @@ describe('BasicAuthenticationProvider', () => { it('passes query string parameters to the login page.', async () => { await expect( provider.logout( - httpServerMock.createKibanaRequest({ query: { next: '/app/ml', msg: 'SESSION_EXPIRED' } }) + httpServerMock.createKibanaRequest({ + query: { next: '/app/ml', msg: 'SESSION_EXPIRED' }, + }), + {} ) ).resolves.toEqual( DeauthenticationResult.redirectTo('/base-path/login?next=%2Fapp%2Fml&msg=SESSION_EXPIRED') diff --git a/x-pack/plugins/security/server/authentication/providers/basic.ts b/x-pack/plugins/security/server/authentication/providers/basic.ts index 76a9f936eca48..83d4ea689f46a 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.ts @@ -34,6 +34,16 @@ interface ProviderState { authorization?: string; } +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication and client + // can be redirected to the login page where they can enter username and password. + return canRedirectRequest(request) && request.route.options.authRequired === true; +} + /** * Provider that supports request authentication via Basic HTTP Authentication. */ @@ -92,7 +102,7 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { } // If state isn't present let's redirect user to the login page. - if (canRedirectRequest(request)) { + if (canStartNewSession(request)) { this.logger.debug('Redirecting request to Login page.'); const basePath = this.options.basePath.get(request); return AuthenticationResult.redirectTo( @@ -106,8 +116,15 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { /** * Redirects user to the login page preserving query string parameters. * @param request Request instance. + * @param [state] Optional state object associated with the provider. */ - public async logout(request: KibanaRequest) { + public async logout(request: KibanaRequest, state?: ProviderState | null) { + this.logger.debug(`Trying to log user out via ${request.url.path}.`); + + if (!state) { + return DeauthenticationResult.notHandled(); + } + // Query string may contain the path where logout has been called or // logout reason that login page may need to know. const queryString = request.url.search || `?msg=LOGGED_OUT`; @@ -134,7 +151,7 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Trying to authenticate via state.'); if (!authorization) { - this.logger.debug('Access token is not found in state.'); + this.logger.debug('Authorization header is not found in state.'); return AuthenticationResult.notHandled(); } diff --git a/x-pack/plugins/security/server/authentication/providers/http.test.ts b/x-pack/plugins/security/server/authentication/providers/http.test.ts index 65fbd7cd9f4ad..47715670e4697 100644 --- a/x-pack/plugins/security/server/authentication/providers/http.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/http.test.ts @@ -32,7 +32,7 @@ function expectAuthenticateCall( describe('HTTPAuthenticationProvider', () => { let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'http' }); }); it('throws if `schemes` are not specified', () => { diff --git a/x-pack/plugins/security/server/authentication/providers/index.ts b/x-pack/plugins/security/server/authentication/providers/index.ts index cd8f5a70c64e3..048afb6190d18 100644 --- a/x-pack/plugins/security/server/authentication/providers/index.ts +++ b/x-pack/plugins/security/server/authentication/providers/index.ts @@ -11,8 +11,8 @@ export { } from './base'; export { BasicAuthenticationProvider } from './basic'; export { KerberosAuthenticationProvider } from './kerberos'; -export { SAMLAuthenticationProvider, isSAMLRequestQuery, SAMLLoginStep } from './saml'; +export { SAMLAuthenticationProvider, SAMLLogin } from './saml'; export { TokenAuthenticationProvider } from './token'; -export { OIDCAuthenticationProvider, OIDCAuthenticationFlow } from './oidc'; +export { OIDCAuthenticationProvider, OIDCLogin } from './oidc'; export { PKIAuthenticationProvider } from './pki'; export { HTTPAuthenticationProvider } from './http'; diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index 955805296e2bd..6eb47cfa83e32 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -14,6 +14,7 @@ import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } import { ElasticsearchErrorHelpers, IClusterClient, + KibanaRequest, ScopeableRequest, } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; @@ -36,43 +37,13 @@ describe('KerberosAuthenticationProvider', () => { let provider: KerberosAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'kerberos' }); provider = new KerberosAuthenticationProvider(mockOptions); }); - describe('`authenticate` method', () => { - it('does not handle authentication via `authorization` header with non-negotiate scheme.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-token' }, - }); - - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.notHandled() - ); - - expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - expect(request.headers.authorization).toBe('Bearer some-token'); - }); - - it('does not handle authentication via `authorization` header with non-negotiate scheme even if state contains a valid token.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-token' }, - }); - const tokenPair = { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }; - - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.notHandled() - ); - - expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - expect(request.headers.authorization).toBe('Bearer some-token'); - }); - + function defineCommonLoginAndAuthenticateTests( + operation: (request: KibanaRequest) => Promise + ) { it('does not handle requests that can be authenticated without `Negotiate` header.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); @@ -80,9 +51,7 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({}); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.notHandled() - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expectAuthenticateCall(mockOptions.client, { headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, @@ -98,33 +67,13 @@ describe('KerberosAuthenticationProvider', () => { ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.notHandled() - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expectAuthenticateCall(mockOptions.client, { headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, }); }); - it('fails if state is present, but backend does not support Kerberos.', async () => { - const request = httpServerMock.createKibanaRequest(); - const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' }; - - const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.tokens.refresh.mockResolvedValue(null); - - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); - - expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - }); - it('fails with `Negotiate` challenge if backend supports Kerberos.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); @@ -137,7 +86,7 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, null)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.failed(failureReason, { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, }) @@ -156,9 +105,7 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); expectAuthenticateCall(mockOptions.client, { headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, @@ -179,7 +126,7 @@ describe('KerberosAuthenticationProvider', () => { refresh_token: 'some-refresh-token', }); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'kerberos' }, { @@ -215,7 +162,7 @@ describe('KerberosAuthenticationProvider', () => { kerberos_authentication_response_token: 'response-token', }); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'kerberos' }, { @@ -249,7 +196,7 @@ describe('KerberosAuthenticationProvider', () => { ); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized(), { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate response-token' }, }) @@ -274,7 +221,7 @@ describe('KerberosAuthenticationProvider', () => { ); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized(), { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, }) @@ -295,9 +242,7 @@ describe('KerberosAuthenticationProvider', () => { const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, @@ -320,9 +265,7 @@ describe('KerberosAuthenticationProvider', () => { refresh_token: 'some-refresh-token', }); - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); expectAuthenticateCall(mockOptions.client, { headers: { authorization: 'Bearer some-token' }, @@ -334,6 +277,74 @@ describe('KerberosAuthenticationProvider', () => { expect(request.headers.authorization).toBe('negotiate spnego'); }); + } + + describe('`login` method', () => { + defineCommonLoginAndAuthenticateTests(request => provider.login(request)); + }); + + describe('`authenticate` method', () => { + defineCommonLoginAndAuthenticateTests(request => provider.authenticate(request, null)); + + it('does not handle authentication via `authorization` header with non-negotiate scheme.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, + }); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + }); + + it('does not handle authentication via `authorization` header with non-negotiate scheme even if state contains a valid token.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, + }); + const tokenPair = { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }; + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + }); + + it('fails if state is present, but backend does not support Kerberos.', async () => { + const request = httpServerMock.createKibanaRequest(); + const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' }; + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + mockOptions.tokens.refresh.mockResolvedValue(null); + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); + + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + }); + + it('does not start SPNEGO if request does not require authentication.', async () => { + const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); it('succeeds if state contains a valid token.', async () => { const user = mockAuthenticatedUser(); @@ -454,6 +465,29 @@ describe('KerberosAuthenticationProvider', () => { expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); }); + + it('does not re-start SPNEGO if both access and refresh tokens from the state are expired.', async () => { + const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false }); + const tokenPair = { accessToken: 'expired-token', refreshToken: 'some-valid-refresh-token' }; + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError( + new (errors.AuthenticationException as any)('Unauthorized', { + body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, + }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + mockOptions.tokens.refresh.mockResolvedValue(null); + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + }); }); describe('`logout` method', () => { diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index dbd0a438d71c9..c4bbe554a3da1 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -27,6 +27,15 @@ type ProviderState = TokenPair; */ const WWWAuthenticateHeaderName = 'WWW-Authenticate'; +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication. + return request.route.options.authRequired === true; +} + /** * Provider that supports Kerberos request authentication. */ @@ -36,6 +45,20 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { */ static readonly type = 'kerberos'; + /** + * Performs initial login request. + * @param request Request instance. + */ + public async login(request: KibanaRequest) { + this.logger.debug('Trying to perform a login.'); + + if (HTTPAuthorizationHeader.parseFromRequest(request)?.scheme.toLowerCase() === 'negotiate') { + return await this.authenticateWithNegotiateScheme(request); + } + + return await this.authenticateViaSPNEGO(request); + } + /** * Performs Kerberos request authentication. * @param request Request instance. @@ -66,7 +89,7 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { // If we couldn't authenticate by means of all methods above, let's try to check if Elasticsearch can // start authentication mechanism negotiation, otherwise just return authentication result we have. - return authenticationResult.notHandled() + return authenticationResult.notHandled() && canStartNewSession(request) ? await this.authenticateViaSPNEGO(request, state) : authenticationResult; } @@ -239,10 +262,10 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { // If refresh token is no longer valid, then we should clear session and renegotiate using SPNEGO. if (refreshedTokenPair === null) { - this.logger.debug( - 'Both access and refresh tokens are expired. Re-initiating SPNEGO handshake.' - ); - return this.authenticateViaSPNEGO(request, state); + this.logger.debug('Both access and refresh tokens are expired.'); + return canStartNewSession(request) + ? this.authenticateViaSPNEGO(request, state) + : AuthenticationResult.notHandled(); } try { diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index 6a4ba1ccb41e2..14fe42aac7599 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -18,7 +18,7 @@ import { } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { OIDCAuthenticationProvider, OIDCAuthenticationFlow, ProviderLoginAttempt } from './oidc'; +import { OIDCAuthenticationProvider, OIDCLogin, ProviderLoginAttempt } from './oidc'; function expectAuthenticateCall( mockClusterClient: jest.Mocked, @@ -36,7 +36,7 @@ describe('OIDCAuthenticationProvider', () => { let provider: OIDCAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'oidc' }); provider = new OIDCAuthenticationProvider(mockOptions, { realm: 'oidc1' }); }); @@ -72,7 +72,7 @@ describe('OIDCAuthenticationProvider', () => { await expect( provider.login(request, { - flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, + type: OIDCLogin.LoginInitiatedBy3rdParty, iss: 'theissuer', loginHint: 'loginhint', }) @@ -84,7 +84,14 @@ describe('OIDCAuthenticationProvider', () => { '&state=statevalue' + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + '&login_hint=loginhint', - { state: { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/' } } + { + state: { + state: 'statevalue', + nonce: 'noncevalue', + nextURL: '/mock-server-basepath/', + realm: 'oidc1', + }, + } ) ); @@ -93,6 +100,50 @@ describe('OIDCAuthenticationProvider', () => { }); }); + it('redirects user initiated login attempts to the OpenId Connect Provider.', async () => { + const request = httpServerMock.createKibanaRequest(); + + mockOptions.client.callAsInternalUser.mockResolvedValue({ + state: 'statevalue', + nonce: 'noncevalue', + redirect: + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + + '&login_hint=loginhint', + }); + + await expect( + provider.login(request, { + type: OIDCLogin.LoginInitiatedByUser, + redirectURLPath: '/mock-server-basepath/app/super-kibana', + }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + + '&login_hint=loginhint', + { + state: { + state: 'statevalue', + nonce: 'noncevalue', + nextURL: '/mock-server-basepath/app/super-kibana', + realm: 'oidc1', + }, + } + ) + ); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { + body: { realm: 'oidc1' }, + }); + }); + function defineAuthenticationFlowTests( getMocks: () => { request: KibanaRequest; @@ -113,10 +164,15 @@ describe('OIDCAuthenticationProvider', () => { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/some-path', + realm: 'oidc1', }) ).resolves.toEqual( AuthenticationResult.redirectTo('/base-path/some-path', { - state: { accessToken: 'some-token', refreshToken: 'some-refresh-token' }, + state: { + accessToken: 'some-token', + refreshToken: 'some-refresh-token', + realm: 'oidc1', + }, }) ); @@ -137,7 +193,7 @@ describe('OIDCAuthenticationProvider', () => { const { request, attempt } = getMocks(); await expect( - provider.login(request, attempt, { nextURL: '/base-path/some-path' }) + provider.login(request, attempt, { nextURL: '/base-path/some-path', realm: 'oidc1' }) ).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( @@ -153,7 +209,11 @@ describe('OIDCAuthenticationProvider', () => { const { request, attempt } = getMocks(); await expect( - provider.login(request, attempt, { state: 'statevalue', nonce: 'noncevalue' }) + provider.login(request, attempt, { + state: 'statevalue', + nonce: 'noncevalue', + realm: 'oidc1', + }) ).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( @@ -168,7 +228,7 @@ describe('OIDCAuthenticationProvider', () => { it('fails if session state is not presented.', async () => { const { request, attempt } = getMocks(); - await expect(provider.login(request, attempt, {})).resolves.toEqual( + await expect(provider.login(request, attempt, {} as any)).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( 'Response session state does not have corresponding state or nonce parameters or redirect URL.' @@ -192,6 +252,7 @@ describe('OIDCAuthenticationProvider', () => { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/some-path', + realm: 'oidc1', }) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -207,6 +268,20 @@ describe('OIDCAuthenticationProvider', () => { } ); }); + + it('fails if realm from state is different from the realm provider is configured with.', async () => { + const { request, attempt } = getMocks(); + + await expect(provider.login(request, attempt, { realm: 'other-realm' })).resolves.toEqual( + AuthenticationResult.failed( + Boom.unauthorized( + 'State based on realm "other-realm", but provider with the name "oidc" is configured to use realm "oidc1".' + ) + ) + ); + + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); } describe('authorization code flow', () => { @@ -215,7 +290,7 @@ describe('OIDCAuthenticationProvider', () => { path: '/api/security/oidc/callback?code=somecodehere&state=somestatehere', }), attempt: { - flow: OIDCAuthenticationFlow.AuthorizationCode, + type: OIDCLogin.LoginWithAuthorizationCodeFlow, authenticationResponseURI: '/api/security/oidc/callback?code=somecodehere&state=somestatehere', }, @@ -230,7 +305,7 @@ describe('OIDCAuthenticationProvider', () => { '/api/security/oidc/callback?authenticationResponseURI=http://kibana/api/security/oidc/implicit#id_token=sometoken', }), attempt: { - flow: OIDCAuthenticationFlow.Implicit, + type: OIDCLogin.LoginWithImplicitFlow, authenticationResponseURI: 'http://kibana/api/security/oidc/implicit#id_token=sometoken', }, expectedRedirectURI: 'http://kibana/api/security/oidc/implicit#id_token=sometoken', @@ -246,6 +321,13 @@ describe('OIDCAuthenticationProvider', () => { ); }); + it('does not handle non-AJAX request that does not require authentication.', async () => { + const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + }); + it('redirects non-AJAX request that can not be authenticated to the OpenId Connect Provider.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); @@ -272,6 +354,7 @@ describe('OIDCAuthenticationProvider', () => { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/s/foo/some-path', + realm: 'oidc1', }, } ) @@ -310,7 +393,9 @@ describe('OIDCAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'oidc' }, { authHeaders: { authorization } } @@ -344,6 +429,7 @@ describe('OIDCAuthenticationProvider', () => { provider.authenticate(request, { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', + realm: 'oidc1', }) ).resolves.toEqual(AuthenticationResult.notHandled()); @@ -364,9 +450,9 @@ describe('OIDCAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual(AuthenticationResult.failed(failureReason)); expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); @@ -401,12 +487,18 @@ describe('OIDCAuthenticationProvider', () => { refreshToken: 'new-refresh-token', }); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'oidc' }, { authHeaders: { authorization: 'Bearer new-access-token' }, - state: { accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }, + state: { + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + realm: 'oidc1', + }, } ) ); @@ -434,9 +526,9 @@ describe('OIDCAuthenticationProvider', () => { }; mockOptions.tokens.refresh.mockRejectedValue(refreshFailureReason); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.failed(refreshFailureReason as any) - ); + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual(AuthenticationResult.failed(refreshFailureReason as any)); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); @@ -470,7 +562,9 @@ describe('OIDCAuthenticationProvider', () => { mockOptions.tokens.refresh.mockResolvedValue(null); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual( AuthenticationResult.redirectTo( 'https://op-host/path/login?response_type=code' + '&scope=openid%20profile%20email' + @@ -482,6 +576,7 @@ describe('OIDCAuthenticationProvider', () => { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/s/foo/some-path', + realm: 'oidc1', }, } ) @@ -515,7 +610,9 @@ describe('OIDCAuthenticationProvider', () => { mockOptions.tokens.refresh.mockResolvedValue(null); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual( AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) ); @@ -528,6 +625,44 @@ describe('OIDCAuthenticationProvider', () => { expect(request.headers).not.toHaveProperty('authorization'); }); + + it('fails for non-AJAX requests that do not require authentication with user friendly message if refresh token is expired.', async () => { + const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false, headers: {} }); + const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; + const authorization = `Bearer ${tokenPair.accessToken}`; + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + mockOptions.tokens.refresh.mockResolvedValue(null); + + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual( + AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) + ); + + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + + it('fails if realm from state is different from the realm provider is configured with.', async () => { + const request = httpServerMock.createKibanaRequest(); + await expect(provider.authenticate(request, { realm: 'other-realm' })).resolves.toEqual( + AuthenticationResult.failed( + Boom.unauthorized( + 'State based on realm "other-realm", but provider with the name "oidc" is configured to use realm "oidc1".' + ) + ) + ); + }); }); describe('`logout` method', () => { @@ -538,11 +673,11 @@ describe('OIDCAuthenticationProvider', () => { DeauthenticationResult.notHandled() ); - await expect(provider.logout(request, {})).resolves.toEqual( + await expect(provider.logout(request, {} as any)).resolves.toEqual( DeauthenticationResult.notHandled() ); - await expect(provider.logout(request, { nonce: 'x' })).resolves.toEqual( + await expect(provider.logout(request, { nonce: 'x', realm: 'oidc1' })).resolves.toEqual( DeauthenticationResult.notHandled() ); @@ -557,9 +692,9 @@ describe('OIDCAuthenticationProvider', () => { const failureReason = new Error('Realm is misconfigured!'); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - await expect(provider.logout(request, { accessToken, refreshToken })).resolves.toEqual( - DeauthenticationResult.failed(failureReason) - ); + await expect( + provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) + ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcLogout', { @@ -574,7 +709,9 @@ describe('OIDCAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); - await expect(provider.logout(request, { accessToken, refreshToken })).resolves.toEqual( + await expect( + provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) + ).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); @@ -593,7 +730,9 @@ describe('OIDCAuthenticationProvider', () => { redirect: 'http://fake-idp/logout&id_token_hint=thehint', }); - await expect(provider.logout(request, { accessToken, refreshToken })).resolves.toEqual( + await expect( + provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) + ).resolves.toEqual( DeauthenticationResult.redirectTo('http://fake-idp/logout&id_token_hint=thehint') ); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index 21bce028b0d98..f8e6ac0f9b5d0 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -19,23 +19,25 @@ import { } from './base'; /** - * Describes possible OpenID Connect authentication flows. + * Describes possible OpenID Connect login flows. */ -export enum OIDCAuthenticationFlow { - Implicit = 'implicit', - AuthorizationCode = 'authorization-code', - InitiatedBy3rdParty = 'initiated-by-3rd-party', +export enum OIDCLogin { + LoginInitiatedByUser = 'login-by-user', + LoginWithImplicitFlow = 'login-implicit', + LoginWithAuthorizationCodeFlow = 'login-authorization-code', + LoginInitiatedBy3rdParty = 'login-initiated-by-3rd-party', } /** * Describes the parameters that are required by the provider to process the initial login request. */ export type ProviderLoginAttempt = + | { type: OIDCLogin.LoginInitiatedByUser; redirectURLPath: string } | { - flow: OIDCAuthenticationFlow.Implicit | OIDCAuthenticationFlow.AuthorizationCode; + type: OIDCLogin.LoginWithImplicitFlow | OIDCLogin.LoginWithAuthorizationCodeFlow; authenticationResponseURI: string; } - | { flow: OIDCAuthenticationFlow.InitiatedBy3rdParty; iss: string; loginHint?: string }; + | { type: OIDCLogin.LoginInitiatedBy3rdParty; iss: string; loginHint?: string }; /** * The state supported by the provider (for the OpenID Connect handshake or established session). @@ -57,6 +59,21 @@ interface ProviderState extends Partial { * URL to redirect user to after successful OpenID Connect handshake. */ nextURL?: string; + + /** + * The name of the OpenID Connect realm that was used to establish session. + */ + realm: string; +} + +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication and client + // can be redirected to the Identity Provider where they can authenticate. + return canRedirectRequest(request) && request.route.options.authRequired === true; } /** @@ -102,15 +119,38 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { ) { this.logger.debug('Trying to perform a login.'); - if (attempt.flow === OIDCAuthenticationFlow.InitiatedBy3rdParty) { - this.logger.debug('Authentication has been initiated by a Third Party.'); + // It may happen that Kibana is re-configured to use different realm for the same provider name, + // we should clear such session an log user out. + if (state?.realm && state.realm !== this.realm) { + const message = `State based on realm "${state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`; + this.logger.debug(message); + return AuthenticationResult.failed(Boom.unauthorized(message)); + } + + if (attempt.type === OIDCLogin.LoginInitiatedBy3rdParty) { + this.logger.debug('Login has been initiated by a Third Party.'); // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in // another tab) const oidcPrepareParams = attempt.loginHint ? { iss: attempt.iss, login_hint: attempt.loginHint } : { iss: attempt.iss }; - return this.initiateOIDCAuthentication(request, oidcPrepareParams); - } else if (attempt.flow === OIDCAuthenticationFlow.Implicit) { + return this.initiateOIDCAuthentication( + request, + oidcPrepareParams, + `${this.options.basePath.serverBasePath}/` + ); + } + + if (attempt.type === OIDCLogin.LoginInitiatedByUser) { + this.logger.debug(`Login has been initiated by a user.`); + return this.initiateOIDCAuthentication( + request, + { realm: this.realm }, + attempt.redirectURLPath + ); + } + + if (attempt.type === OIDCLogin.LoginWithImplicitFlow) { this.logger.debug('OpenID Connect Implicit Authentication flow is used.'); } else { this.logger.debug('OpenID Connect Authorization Code Authentication flow is used.'); @@ -136,6 +176,14 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.notHandled(); } + // It may happen that Kibana is re-configured to use different realm for the same provider name, + // we should clear such session an log user out. + if (state?.realm && state.realm !== this.realm) { + const message = `State based on realm "${state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`; + this.logger.debug(message); + return AuthenticationResult.failed(Boom.unauthorized(message)); + } + let authenticationResult = AuthenticationResult.notHandled(); if (state) { authenticationResult = await this.authenticateViaState(request, state); @@ -151,7 +199,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // initiate an OpenID Connect based authentication, otherwise just return the authentication result we have. // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in // another tab) - return authenticationResult.notHandled() + return authenticationResult.notHandled() && canStartNewSession(request) ? await this.initiateOIDCAuthentication(request, { realm: this.realm }) : authenticationResult; } @@ -211,7 +259,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Request has been authenticated via OpenID Connect.'); return AuthenticationResult.redirectTo(stateRedirectURL, { - state: { accessToken, refreshToken }, + state: { accessToken, refreshToken, realm: this.realm }, }); } catch (err) { this.logger.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); @@ -224,49 +272,30 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * * @param request Request instance. * @param params OIDC authentication parameters. - * @param [sessionState] Optional state object associated with the provider. + * @param [redirectURLPath] Optional URL user is supposed to be redirected to after successful + * login. If not provided the URL of the specified request is used. */ private async initiateOIDCAuthentication( request: KibanaRequest, params: { realm: string } | { iss: string; login_hint?: string }, - sessionState?: ProviderState | null + redirectURLPath = `${this.options.basePath.get(request)}${request.url.path}` ) { this.logger.debug('Trying to initiate OpenID Connect authentication.'); - // If client can't handle redirect response, we shouldn't initiate OpenID Connect authentication. - if (!canRedirectRequest(request)) { - this.logger.debug('OpenID Connect authentication can not be initiated by AJAX requests.'); - return AuthenticationResult.notHandled(); - } - try { - /* - * Possibly adds the state and nonce parameter that was saved in the user's session state to - * the params. There is no use case where we would have only a state parameter or only a nonce - * parameter in the session state so we only enrich the params object if we have both - */ - const oidcPrepareParams = - sessionState && sessionState.nonce && sessionState.state - ? { ...params, nonce: sessionState.nonce, state: sessionState.state } - : params; // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/oidc/prepare`. - const { state, nonce, redirect } = await this.options.client.callAsInternalUser( - 'shield.oidcPrepare', - { - body: oidcPrepareParams, - } - ); + const { + state, + nonce, + redirect, + } = await this.options.client.callAsInternalUser('shield.oidcPrepare', { body: params }); this.logger.debug('Redirecting to OpenID Connect Provider with authentication request.'); - // If this is a third party initiated login, redirect to the base path - const redirectAfterLogin = `${this.options.basePath.get(request)}${ - 'iss' in params ? '/' : request.url.path - }`; return AuthenticationResult.redirectTo( redirect, // Store the state and nonce parameters in the session state of the user - { state: { state, nonce, nextURL: redirectAfterLogin } } + { state: { state, nonce, nextURL: redirectURLPath, realm: this.realm } } ); } catch (err) { this.logger.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`); @@ -334,7 +363,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // seems logical to do the same on Kibana side and `401` would force user to logout and do full SLO if it's // supported. if (refreshedTokenPair === null) { - if (canRedirectRequest(request)) { + if (canStartNewSession(request)) { this.logger.debug( 'Both elasticsearch access and refresh tokens are expired. Re-initiating OpenID Connect authentication.' ); @@ -356,7 +385,10 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via refreshed token.'); - return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair }); + return AuthenticationResult.succeeded(user, { + authHeaders, + state: { ...refreshedTokenPair, realm: this.realm }, + }); } catch (err) { this.logger.debug(`Failed to refresh elasticsearch access token: ${err.message}`); return AuthenticationResult.failed(err); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.test.ts b/x-pack/plugins/security/server/authentication/providers/pki.test.ts index 044416032a4c3..638bb5732f3c0 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.test.ts @@ -19,6 +19,7 @@ import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } import { ElasticsearchErrorHelpers, IClusterClient, + KibanaRequest, ScopeableRequest, } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; @@ -78,53 +79,21 @@ describe('PKIAuthenticationProvider', () => { let provider: PKIAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'pki' }); provider = new PKIAuthenticationProvider(mockOptions); }); afterEach(() => jest.clearAllMocks()); - describe('`authenticate` method', () => { - it('does not handle authentication via `authorization` header.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-token' }, - }); - - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.notHandled() - ); - - expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - expect(request.headers.authorization).toBe('Bearer some-token'); - }); - - it('does not handle authentication via `authorization` header even if state contains a valid token.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-token' }, - }); - const state = { - accessToken: 'some-valid-token', - peerCertificateFingerprint256: '2A:7A:C2:DD', - }; - - await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.notHandled() - ); - - expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - expect(request.headers.authorization).toBe('Bearer some-token'); - }); - + function defineCommonLoginAndAuthenticateTests( + operation: (request: KibanaRequest) => Promise + ) { it('does not handle requests without certificate.', async () => { const request = httpServerMock.createKibanaRequest({ socket: getMockSocket({ authorized: true }), }); - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.notHandled() - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); @@ -135,58 +104,12 @@ describe('PKIAuthenticationProvider', () => { socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }), }); - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.notHandled() - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); - it('fails with non-401 error if state is available, peer is authorized, but certificate is not available.', async () => { - const request = httpServerMock.createKibanaRequest({ - socket: getMockSocket({ authorized: true }), - }); - - const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - - await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.failed(new Error('Peer certificate is not available')) - ); - - expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); - }); - - it('invalidates token and fails with 401 if state is present, but peer certificate is not.', async () => { - const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() }); - const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - - await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.failed(Boom.unauthorized()) - ); - - expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ - accessToken: state.accessToken, - }); - }); - - it('invalidates token and fails with 401 if new certificate is present, but not authorized.', async () => { - const request = httpServerMock.createKibanaRequest({ - socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }), - }); - const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - - await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.failed(Boom.unauthorized()) - ); - - expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ - accessToken: state.accessToken, - }); - }); - it('gets an access token in exchange to peer certificate chain and stores it in the state.', async () => { const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest({ @@ -202,7 +125,7 @@ describe('PKIAuthenticationProvider', () => { mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'pki' }, { @@ -244,7 +167,7 @@ describe('PKIAuthenticationProvider', () => { mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'pki' }, { @@ -266,6 +189,156 @@ describe('PKIAuthenticationProvider', () => { expect(request.headers).not.toHaveProperty('authorization'); }); + it('fails if could not retrieve an access token in exchange to peer certificate chain.', async () => { + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'), + }), + }); + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + + await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, + }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + + it('fails if could not retrieve user using the new access token.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: {}, + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'), + }), + }); + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); + + await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, + }); + + expectAuthenticateCall(mockOptions.client, { + headers: { authorization: 'Bearer access-token' }, + }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + } + + describe('`login` method', () => { + defineCommonLoginAndAuthenticateTests(request => provider.login(request)); + }); + + describe('`authenticate` method', () => { + defineCommonLoginAndAuthenticateTests(request => provider.authenticate(request, null)); + + it('does not handle authentication via `authorization` header.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, + }); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + }); + + it('does not handle authentication via `authorization` header even if state contains a valid token.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, + }); + const state = { + accessToken: 'some-valid-token', + peerCertificateFingerprint256: '2A:7A:C2:DD', + }; + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + }); + + it('does not exchange peer certificate to access token if request does not require authentication.', async () => { + const request = httpServerMock.createKibanaRequest({ + routeAuthRequired: false, + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']), + }), + }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); + + it('fails with non-401 error if state is available, peer is authorized, but certificate is not available.', async () => { + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ authorized: true }), + }); + + const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(new Error('Peer certificate is not available')) + ); + + expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); + }); + + it('invalidates token and fails with 401 if state is present, but peer certificate is not.', async () => { + const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() }); + const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized()) + ); + + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ + accessToken: state.accessToken, + }); + }); + + it('invalidates token and fails with 401 if new certificate is present, but not authorized.', async () => { + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }), + }); + const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized()) + ); + + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ + accessToken: state.accessToken, + }); + }); + it('invalidates existing token and gets a new one if fingerprints do not match.', async () => { const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest({ @@ -351,75 +424,45 @@ describe('PKIAuthenticationProvider', () => { expect(request.headers).not.toHaveProperty('authorization'); }); - it('fails with 401 if existing token is expired, but certificate is not present.', async () => { - const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() }); + it('does not exchange peer certificate to a new access token even if existing token is expired and request does not require authentication.', async () => { + const request = httpServerMock.createKibanaRequest({ + routeAuthRequired: false, + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']), + }), + }); const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + mockScopedClusterClient.callAsCurrentUser.mockRejectedValueOnce( ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.failed(Boom.unauthorized()) + AuthenticationResult.notHandled() ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - - expect(request.headers).not.toHaveProperty('authorization'); - }); - - it('fails if could not retrieve an access token in exchange to peer certificate chain.', async () => { - const request = httpServerMock.createKibanaRequest({ - socket: getMockSocket({ - authorized: true, - peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'), - }), - }); - - const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); - - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { - body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, - }); - expect(request.headers).not.toHaveProperty('authorization'); }); - it('fails if could not retrieve user using the new access token.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: {}, - socket: getMockSocket({ - authorized: true, - peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'), - }), - }); + it('fails with 401 if existing token is expired, but certificate is not present.', async () => { + const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() }); + const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.failed(failureReason) + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized()) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { - body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, - }); - - expectAuthenticateCall(mockOptions.client, { - headers: { authorization: 'Bearer access-token' }, - }); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(request.headers).not.toHaveProperty('authorization'); }); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.ts b/x-pack/plugins/security/server/authentication/providers/pki.ts index db022ff355702..243e5415ad2c2 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.ts @@ -28,6 +28,15 @@ interface ProviderState { peerCertificateFingerprint256: string; } +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication. + return request.route.options.authRequired === true; +} + /** * Provider that supports PKI request authentication. */ @@ -37,6 +46,15 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { */ static readonly type = 'pki'; + /** + * Performs initial login request. + * @param request Request instance. + */ + public async login(request: KibanaRequest) { + this.logger.debug('Trying to perform a login.'); + return await this.authenticateViaPeerCertificate(request); + } + /** * Performs PKI request authentication. * @param request Request instance. @@ -55,12 +73,12 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { authenticationResult = await this.authenticateViaState(request, state); // If access token expired or doesn't match to the certificate fingerprint we should try to get - // a new one in exchange to peer certificate chain. - if ( + // a new one in exchange to peer certificate chain assuming request can initiate new session. + const invalidAccessToken = authenticationResult.notHandled() || (authenticationResult.failed() && - Tokens.isAccessTokenExpiredError(authenticationResult.error)) - ) { + Tokens.isAccessTokenExpiredError(authenticationResult.error)); + if (invalidAccessToken && canStartNewSession(request)) { authenticationResult = await this.authenticateViaPeerCertificate(request); // If we have an active session that we couldn't use to authenticate user and at the same time // we couldn't use peer's certificate to establish a new one, then we should respond with 401 @@ -68,12 +86,15 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { if (authenticationResult.notHandled()) { return AuthenticationResult.failed(Boom.unauthorized()); } + } else if (invalidAccessToken) { + return AuthenticationResult.notHandled(); } } // If we couldn't authenticate by means of all methods above, let's try to check if we can authenticate // request using its peer certificate chain, otherwise just return authentication result we have. - return authenticationResult.notHandled() + // We shouldn't establish new session if authentication isn't required for this particular request. + return authenticationResult.notHandled() && canStartNewSession(request) ? await this.authenticateViaPeerCertificate(request) : authenticationResult; } diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index e00d3b89fb0bf..a7a43a3031571 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -18,7 +18,7 @@ import { } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { SAMLAuthenticationProvider, SAMLLoginStep } from './saml'; +import { SAMLAuthenticationProvider, SAMLLogin } from './saml'; function expectAuthenticateCall( mockClusterClient: jest.Mocked, @@ -36,7 +36,7 @@ describe('SAMLAuthenticationProvider', () => { let provider: SAMLAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'saml' }); provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', maxRedirectURLSize: new ByteSizeValue(100), @@ -86,8 +86,12 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path#some-app' } + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + { + requestId: 'some-request-id', + redirectURL: '/test-base-path/some-path#some-app', + realm: 'test-realm', + } ) ).resolves.toEqual( AuthenticationResult.redirectTo('/test-base-path/some-path#some-app', { @@ -95,6 +99,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'some-token', refreshToken: 'some-refresh-token', + realm: 'test-realm', }, }) ); @@ -111,8 +116,8 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - {} + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + {} as any ) ).resolves.toEqual( AuthenticationResult.failed( @@ -123,6 +128,26 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); + it('fails if realm from state is different from the realm provider is configured with.', async () => { + const request = httpServerMock.createKibanaRequest(); + + await expect( + provider.login( + request, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + { realm: 'other-realm' } + ) + ).resolves.toEqual( + AuthenticationResult.failed( + Boom.unauthorized( + 'State based on realm "other-realm", but provider with the name "saml" is configured to use realm "test-realm".' + ) + ) + ); + + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); + it('redirects to the default location if state contains empty redirect URL.', async () => { const request = httpServerMock.createKibanaRequest(); @@ -134,14 +159,15 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - { requestId: 'some-request-id', redirectURL: '' } + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } ) ).resolves.toEqual( AuthenticationResult.redirectTo('/base-path/', { state: { accessToken: 'user-initiated-login-token', refreshToken: 'user-initiated-login-refresh-token', + realm: 'test-realm', }, }) ); @@ -162,7 +188,7 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login(request, { - step: SAMLLoginStep.SAMLResponseReceived, + type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml', }) ).resolves.toEqual( @@ -170,6 +196,7 @@ describe('SAMLAuthenticationProvider', () => { state: { accessToken: 'idp-initiated-login-token', refreshToken: 'idp-initiated-login-refresh-token', + realm: 'test-realm', }, }) ); @@ -189,8 +216,12 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path' } + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + { + requestId: 'some-request-id', + redirectURL: '/test-base-path/some-path', + realm: 'test-realm', + } ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -201,7 +232,7 @@ describe('SAMLAuthenticationProvider', () => { }); describe('IdP initiated login with existing session', () => { - it('fails if new SAML Response is rejected.', async () => { + it('returns `notHandled` if new SAML Response is rejected.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const authorization = 'Bearer some-valid-token'; @@ -216,14 +247,15 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, { username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', } ) - ).resolves.toEqual(AuthenticationResult.failed(failureReason)); + ).resolves.toEqual(AuthenticationResult.notHandled()); expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); @@ -241,6 +273,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -261,7 +294,7 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, state ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -288,6 +321,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -307,7 +341,7 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, state ) ).resolves.toEqual( @@ -316,6 +350,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'new-valid-token', refreshToken: 'new-valid-refresh-token', + realm: 'test-realm', }, }) ); @@ -342,6 +377,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -361,7 +397,7 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, state ) ).resolves.toEqual( @@ -370,6 +406,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'new-user', accessToken: 'new-valid-token', refreshToken: 'new-valid-refresh-token', + realm: 'test-realm', }, }) ); @@ -392,41 +429,61 @@ describe('SAMLAuthenticationProvider', () => { }); describe('User initiated login with captured redirect URL', () => { - it('fails if state is not available', async () => { + it('fails if redirectURLPath is not available', async () => { const request = httpServerMock.createKibanaRequest(); await expect( provider.login(request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: '#some-fragment', }) ).resolves.toEqual( AuthenticationResult.failed( - Boom.badRequest('State does not include URL path to redirect to.') + Boom.badRequest('State or login attempt does not include URL path to redirect to.') ) ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); - it('does not handle AJAX requests.', async () => { - const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); + it('redirects requests to the IdP remembering combined redirect URL.', async () => { + const request = httpServerMock.createKibanaRequest(); + + mockOptions.client.callAsInternalUser.mockResolvedValue({ + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + }); await expect( provider.login( request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: '#some-fragment', }, - { redirectURL: '/test-base-path/some-path' } + { redirectURL: '/test-base-path/some-path', realm: 'test-realm' } ) - ).resolves.toEqual(AuthenticationResult.notHandled()); + ).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://idp-host/path/login?SAMLRequest=some%20request%20', + { + state: { + requestId: 'some-request-id', + redirectURL: '/test-base-path/some-path#some-fragment', + realm: 'test-realm', + }, + } + ) + ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { + body: { realm: 'test-realm' }, + }); + + expect(mockOptions.logger.warn).not.toHaveBeenCalled(); }); - it('redirects non-AJAX requests to the IdP remembering combined redirect URL.', async () => { + it('redirects requests to the IdP remembering combined redirect URL if path is provided in attempt.', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser.mockResolvedValue({ @@ -438,10 +495,11 @@ describe('SAMLAuthenticationProvider', () => { provider.login( request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, + redirectURLPath: '/test-base-path/some-path', redirectURLFragment: '#some-fragment', }, - { redirectURL: '/test-base-path/some-path' } + null ) ).resolves.toEqual( AuthenticationResult.redirectTo( @@ -450,6 +508,7 @@ describe('SAMLAuthenticationProvider', () => { state: { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path#some-fragment', + realm: 'test-realm', }, } ) @@ -474,10 +533,10 @@ describe('SAMLAuthenticationProvider', () => { provider.login( request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: '../some-fragment', }, - { redirectURL: '/test-base-path/some-path' } + { redirectURL: '/test-base-path/some-path', realm: 'test-realm' } ) ).resolves.toEqual( AuthenticationResult.redirectTo( @@ -486,6 +545,7 @@ describe('SAMLAuthenticationProvider', () => { state: { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path#../some-fragment', + realm: 'test-realm', }, } ) @@ -501,7 +561,7 @@ describe('SAMLAuthenticationProvider', () => { ); }); - it('redirects non-AJAX requests to the IdP remembering only redirect URL path if fragment is too large.', async () => { + it('redirects requests to the IdP remembering only redirect URL path if fragment is too large.', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser.mockResolvedValue({ @@ -513,10 +573,10 @@ describe('SAMLAuthenticationProvider', () => { provider.login( request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: '#some-fragment'.repeat(10), }, - { redirectURL: '/test-base-path/some-path' } + { redirectURL: '/test-base-path/some-path', realm: 'test-realm' } ) ).resolves.toEqual( AuthenticationResult.redirectTo( @@ -525,6 +585,7 @@ describe('SAMLAuthenticationProvider', () => { state: { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path', + realm: 'test-realm', }, } ) @@ -540,6 +601,40 @@ describe('SAMLAuthenticationProvider', () => { ); }); + it('redirects requests to the IdP remembering base path if redirect URL path in attempt is too large.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.client.callAsInternalUser.mockResolvedValue({ + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + }); + + await expect( + provider.login( + request, + { + type: SAMLLogin.LoginInitiatedByUser, + redirectURLPath: `/s/foo/${'some-path'.repeat(11)}`, + redirectURLFragment: '#some-fragment', + }, + null + ) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://idp-host/path/login?SAMLRequest=some%20request%20', + { state: { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } } + ) + ); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { + body: { realm: 'test-realm' }, + }); + + expect(mockOptions.logger.warn).toHaveBeenCalledTimes(1); + expect(mockOptions.logger.warn).toHaveBeenCalledWith( + 'Max URL path size should not exceed 100b but it was 106b. URL is not captured.' + ); + }); + it('fails if SAML request preparation fails.', async () => { const request = httpServerMock.createKibanaRequest(); @@ -550,10 +645,10 @@ describe('SAMLAuthenticationProvider', () => { provider.login( request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: '#some-fragment', }, - { redirectURL: '/test-base-path/some-path' } + { redirectURL: '/test-base-path/some-path', realm: 'test-realm' } ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -573,6 +668,13 @@ describe('SAMLAuthenticationProvider', () => { ); }); + it('does not handle non-AJAX request that does not require authentication.', async () => { + const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + }); + it('does not handle authentication via `authorization` header.', async () => { const request = httpServerMock.createKibanaRequest({ headers: { authorization: 'Bearer some-token' }, @@ -596,6 +698,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', }) ).resolves.toEqual(AuthenticationResult.notHandled()); @@ -613,8 +716,8 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/api/security/saml/capture-url-fragment', - { state: { redirectURL: '/base-path/s/foo/some-path' } } + '/mock-server-basepath/internal/security/saml/capture-url-fragment', + { state: { redirectURL: '/base-path/s/foo/some-path', realm: 'test-realm' } } ) ); @@ -634,7 +737,7 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo( 'https://idp-host/path/login?SAMLRequest=some%20request%20', - { state: { requestId: 'some-request-id', redirectURL: '' } } + { state: { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } } ) ); @@ -672,6 +775,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -697,6 +801,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -721,6 +826,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'expired-token', refreshToken: 'valid-refresh-token', + realm: 'test-realm', }; mockOptions.client.asScoped.mockImplementation(scopeableRequest => { @@ -755,6 +861,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'new-access-token', refreshToken: 'new-refresh-token', + realm: 'test-realm', }, } ) @@ -772,6 +879,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'expired-token', refreshToken: 'invalid-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -805,6 +913,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'expired-token', refreshToken: 'expired-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -830,12 +939,45 @@ describe('SAMLAuthenticationProvider', () => { expect(request.headers).not.toHaveProperty('authorization'); }); + it('fails for non-AJAX requests that do not require authentication with user friendly message if refresh token is expired.', async () => { + const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false, headers: {} }); + const state = { + username: 'user', + accessToken: 'expired-token', + refreshToken: 'expired-refresh-token', + realm: 'test-realm', + }; + const authorization = `Bearer ${state.accessToken}`; + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + mockOptions.tokens.refresh.mockResolvedValue(null); + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) + ); + + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); + + expectAuthenticateCall(mockOptions.client, { + headers: { authorization }, + }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + it('re-capture URL for non-AJAX requests if refresh token is expired.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} }); const state = { username: 'user', accessToken: 'expired-token', refreshToken: 'expired-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -849,8 +991,8 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.authenticate(request, state)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/api/security/saml/capture-url-fragment', - { state: { redirectURL: '/base-path/s/foo/some-path' } } + '/mock-server-basepath/internal/security/saml/capture-url-fragment', + { state: { redirectURL: '/base-path/s/foo/some-path', realm: 'test-realm' } } ) ); @@ -871,6 +1013,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'expired-token', refreshToken: 'expired-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -890,7 +1033,7 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.authenticate(request, state)).resolves.toEqual( AuthenticationResult.redirectTo( 'https://idp-host/path/login?SAMLRequest=some%20request%20', - { state: { requestId: 'some-request-id', redirectURL: '' } } + { state: { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } } ) ); @@ -908,6 +1051,17 @@ describe('SAMLAuthenticationProvider', () => { 'Max URL path size should not exceed 100b but it was 107b. URL is not captured.' ); }); + + it('fails if realm from state is different from the realm provider is configured with.', async () => { + const request = httpServerMock.createKibanaRequest(); + await expect(provider.authenticate(request, { realm: 'other-realm' })).resolves.toEqual( + AuthenticationResult.failed( + Boom.unauthorized( + 'State based on realm "other-realm", but provider with the name "saml" is configured to use realm "test-realm".' + ) + ) + ); + }); }); describe('`logout` method', () => { @@ -934,7 +1088,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); await expect( - provider.logout(request, { username: 'user', accessToken, refreshToken }) + provider.logout(request, { + username: 'user', + accessToken, + refreshToken, + realm: 'test-realm', + }) ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); @@ -967,7 +1126,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); await expect( - provider.logout(request, { username: 'user', accessToken, refreshToken }) + provider.logout(request, { + username: 'user', + accessToken, + refreshToken, + realm: 'test-realm', + }) ).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); @@ -986,7 +1150,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: undefined }); await expect( - provider.logout(request, { username: 'user', accessToken, refreshToken }) + provider.logout(request, { + username: 'user', + accessToken, + refreshToken, + realm: 'test-realm', + }) ).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); @@ -1007,7 +1176,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); await expect( - provider.logout(request, { username: 'user', accessToken, refreshToken }) + provider.logout(request, { + username: 'user', + accessToken, + refreshToken, + realm: 'test-realm', + }) ).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); @@ -1028,6 +1202,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'x-saml-token', refreshToken: 'x-saml-refresh-token', + realm: 'test-realm', }) ).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') @@ -1079,7 +1254,12 @@ describe('SAMLAuthenticationProvider', () => { }); await expect( - provider.logout(request, { username: 'user', accessToken, refreshToken }) + provider.logout(request, { + username: 'user', + accessToken, + refreshToken, + realm: 'test-realm', + }) ).resolves.toEqual( DeauthenticationResult.redirectTo('http://fake-idp/SLO?SAMLRequest=7zlH37H') ); @@ -1099,6 +1279,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'x-saml-token', refreshToken: 'x-saml-refresh-token', + realm: 'test-realm', }) ).resolves.toEqual( DeauthenticationResult.redirectTo('http://fake-idp/SLO?SAMLRequest=7zlH37H') diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index ddf6814989a49..e14d34d1901eb 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -32,37 +32,53 @@ interface ProviderState extends Partial { * initiate SAML handshake and where we should redirect user after successful authentication. */ redirectURL?: string; + + /** + * The name of the SAML realm that was used to establish session. + */ + realm: string; } /** - * Describes possible SAML Login steps. + * Describes possible SAML Login flows. */ -export enum SAMLLoginStep { +export enum SAMLLogin { /** - * The final login step when IdP responds with SAML Response payload. + * The login flow when user initiates SAML handshake (SP Initiated Login). */ - SAMLResponseReceived = 'saml-response-received', + LoginInitiatedByUser = 'login-by-user', /** - * The login step when we've captured user URL fragment and ready to start SAML handshake. + * The login flow when IdP responds with SAML Response payload (last step of the SP Initiated + * Login or IdP initiated Login). */ - RedirectURLFragmentCaptured = 'redirect-url-fragment-captured', + LoginWithSAMLResponse = 'login-saml-response', } /** * Describes the parameters that are required by the provider to process the initial login request. */ type ProviderLoginAttempt = - | { step: SAMLLoginStep.RedirectURLFragmentCaptured; redirectURLFragment: string } - | { step: SAMLLoginStep.SAMLResponseReceived; samlResponse: string }; + | { type: SAMLLogin.LoginInitiatedByUser; redirectURLPath?: string; redirectURLFragment?: string } + | { type: SAMLLogin.LoginWithSAMLResponse; samlResponse: string }; /** * Checks whether request query includes SAML request from IdP. * @param query Parsed HTTP request query. */ -export function isSAMLRequestQuery(query: any): query is { SAMLRequest: string } { +function isSAMLRequestQuery(query: any): query is { SAMLRequest: string } { return query && query.SAMLRequest; } +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication and client + // can be redirected to the Identity Provider where they can authenticate. + return canRedirectRequest(request) && request.route.options.authRequired === true; +} + /** * Provider that supports SAML request authentication. */ @@ -113,31 +129,23 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { ) { this.logger.debug('Trying to perform a login.'); - if (attempt.step === SAMLLoginStep.RedirectURLFragmentCaptured) { - if (!state || !state.redirectURL) { - const message = 'State does not include URL path to redirect to.'; + // It may happen that Kibana is re-configured to use different realm for the same provider name, + // we should clear such session an log user out. + if (state?.realm && state.realm !== this.realm) { + const message = `State based on realm "${state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`; + this.logger.debug(message); + return AuthenticationResult.failed(Boom.unauthorized(message)); + } + + if (attempt.type === SAMLLogin.LoginInitiatedByUser) { + const redirectURLPath = attempt.redirectURLPath || state?.redirectURL; + if (!redirectURLPath) { + const message = 'State or login attempt does not include URL path to redirect to.'; this.logger.debug(message); return AuthenticationResult.failed(Boom.badRequest(message)); } - let redirectURLFragment = attempt.redirectURLFragment; - if (redirectURLFragment.length > 0 && !redirectURLFragment.startsWith('#')) { - this.logger.warn('Redirect URL fragment does not start with `#`.'); - redirectURLFragment = `#${redirectURLFragment}`; - } - - let redirectURL = `${state.redirectURL}${redirectURLFragment}`; - const redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURL)); - if (this.maxRedirectURLSize.isLessThan(redirectURLSize)) { - this.logger.warn( - `Max URL size should not exceed ${this.maxRedirectURLSize.toString()} but it was ${redirectURLSize.toString()}. Only URL path is captured.` - ); - redirectURL = state.redirectURL; - } else { - this.logger.debug('Captured redirect URL.'); - } - - return this.authenticateViaHandshake(request, redirectURL); + return this.captureRedirectURL(request, redirectURLPath, attempt.redirectURLFragment); } const { samlResponse } = attempt; @@ -186,6 +194,14 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.notHandled(); } + // It may happen that Kibana is re-configured to use different realm for the same provider name, + // we should clear such session an log user out. + if (state?.realm && state.realm !== this.realm) { + const message = `State based on realm "${state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`; + this.logger.debug(message); + return AuthenticationResult.failed(Boom.unauthorized(message)); + } + let authenticationResult = AuthenticationResult.notHandled(); if (state) { authenticationResult = await this.authenticateViaState(request, state); @@ -199,7 +215,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // If we couldn't authenticate by means of all methods above, let's try to capture user URL and // initiate SAML handshake, otherwise just return authentication result we have. - return authenticationResult.notHandled() && canRedirectRequest(request) + return authenticationResult.notHandled() && canStartNewSession(request) ? this.captureRedirectURL(request) : authenticationResult; } @@ -212,15 +228,23 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { public async logout(request: KibanaRequest, state?: ProviderState) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); - if ((!state || !state.accessToken) && !isSAMLRequestQuery(request.query)) { - this.logger.debug('There is neither access token nor SAML session to invalidate.'); + // Normally when there is no active session in Kibana, `logout` method shouldn't do anything + // and user will eventually be redirected to the home page to log in. But when SAML is enabled + // there is a special case when logout is initiated by the IdP or another SP, then IdP will + // request _every_ SP associated with the current user session to do the logout. So if Kibana, + // without an active session, receives such request it shouldn't redirect user to the home page, + // but rather redirect back to IdP with correct logout response and only Elasticsearch knows how + // to do that. + const isIdPInitiatedSLO = isSAMLRequestQuery(request.query); + if (!state?.accessToken && !isIdPInitiatedSLO) { + this.logger.debug('There is no SAML session to invalidate.'); return DeauthenticationResult.notHandled(); } try { - const redirect = isSAMLRequestQuery(request.query) + const redirect = isIdPInitiatedSLO ? await this.performIdPInitiatedSingleLogout(request) - : await this.performUserInitiatedSingleLogout(state!.accessToken!, state!.refreshToken!); + : await this.performUserInitiatedSingleLogout(state?.accessToken!, state?.refreshToken!); // Having non-null `redirect` field within logout response means that IdP // supports SAML Single Logout and we should redirect user to the specified @@ -283,8 +307,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } // When we don't have state and hence request id we assume that SAMLResponse came from the IdP initiated login. + const isIdPInitiatedLogin = !stateRequestId; this.logger.debug( - stateRequestId + !isIdPInitiatedLogin ? 'Login has been previously initiated by Kibana.' : 'Login has been initiated by Identity Provider.' ); @@ -298,7 +323,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { refresh_token: refreshToken, } = await this.options.client.callAsInternalUser('shield.samlAuthenticate', { body: { - ids: stateRequestId ? [stateRequestId] : [], + ids: !isIdPInitiatedLogin ? [stateRequestId] : [], content: samlResponse, realm: this.realm, }, @@ -307,11 +332,17 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Login has been performed with SAML response.'); return AuthenticationResult.redirectTo( stateRedirectURL || `${this.options.basePath.get(request)}/`, - { state: { username, accessToken, refreshToken } } + { state: { username, accessToken, refreshToken, realm: this.realm } } ); } catch (err) { this.logger.debug(`Failed to log in with SAML response: ${err.message}`); - return AuthenticationResult.failed(err); + + // Since we don't know upfront what realm is targeted by the Identity Provider initiated login + // there is a chance that it failed because of realm mismatch and hence we should return + // `notHandled` and give other SAML providers a chance to properly handle it instead. + return isIdPInitiatedLogin + ? AuthenticationResult.notHandled() + : AuthenticationResult.failed(err); } } @@ -336,7 +367,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // First let's try to authenticate via SAML Response payload. const payloadAuthenticationResult = await this.loginWithSAMLResponse(request, samlResponse); - if (payloadAuthenticationResult.failed()) { + if (payloadAuthenticationResult.failed() || payloadAuthenticationResult.notHandled()) { return payloadAuthenticationResult; } @@ -434,7 +465,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // There are two reasons for `400` and not `401`: Elasticsearch search responds with `400` so it seems logical // to do the same on Kibana side and `401` would force user to logout and do full SLO if it's supported. if (refreshedTokenPair === null) { - if (canRedirectRequest(request)) { + if (canStartNewSession(request)) { this.logger.debug( 'Both access and refresh tokens are expired. Capturing redirect URL and re-initiating SAML handshake.' ); @@ -458,7 +489,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Request has been authenticated via refreshed token.'); return AuthenticationResult.succeeded(user, { authHeaders, - state: { username, ...refreshedTokenPair }, + state: { username, realm: this.realm, ...refreshedTokenPair }, }); } catch (err) { this.logger.debug( @@ -476,12 +507,6 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { private async authenticateViaHandshake(request: KibanaRequest, redirectURL: string) { this.logger.debug('Trying to initiate SAML handshake.'); - // If client can't handle redirect response, we shouldn't initiate SAML handshake. - if (!canRedirectRequest(request)) { - this.logger.debug('SAML handshake can not be initiated by AJAX requests.'); - return AuthenticationResult.notHandled(); - } - try { // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/prepare`. @@ -495,7 +520,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Redirecting to Identity Provider with SAML request.'); // Store request id in the state so that we can reuse it once we receive `SAMLResponse`. - return AuthenticationResult.redirectTo(redirect, { state: { requestId, redirectURL } }); + return AuthenticationResult.redirectTo(redirect, { + state: { requestId, redirectURL, realm: this.realm }, + }); } catch (err) { this.logger.debug(`Failed to initiate SAML handshake: ${err.message}`); return AuthenticationResult.failed(err); @@ -545,18 +572,23 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } /** - * Redirects user to the client-side page that will grab URL fragment and redirect user back to Kibana - * to initiate SAML handshake. + * Tries to capture full redirect URL (both path and fragment) and initiate SAML handshake. * @param request Request instance. + * @param [redirectURLPath] Optional URL path user is supposed to be redirected to after successful + * login. If not provided the URL path of the specified request is used. + * @param [redirectURLFragment] Optional URL fragment of the URL user is supposed to be redirected + * to after successful login. If not provided user will be redirected to the client-side page that + * will grab it and redirect user back to Kibana to initiate SAML handshake. */ - private captureRedirectURL(request: KibanaRequest) { - const basePath = this.options.basePath.get(request); - const redirectURL = `${basePath}${request.url.path}`; - + private captureRedirectURL( + request: KibanaRequest, + redirectURLPath = `${this.options.basePath.get(request)}${request.url.path}`, + redirectURLFragment?: string + ) { // If the size of the path already exceeds the maximum allowed size of the URL to store in the // session there is no reason to try to capture URL fragment and we start handshake immediately. // In this case user will be redirected to the Kibana home/root after successful login. - const redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURL)); + let redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURLPath)); if (this.maxRedirectURLSize.isLessThan(redirectURLSize)) { this.logger.warn( `Max URL path size should not exceed ${this.maxRedirectURLSize.toString()} but it was ${redirectURLSize.toString()}. URL is not captured.` @@ -564,9 +596,30 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return this.authenticateViaHandshake(request, ''); } - return AuthenticationResult.redirectTo( - `${this.options.basePath.serverBasePath}/api/security/saml/capture-url-fragment`, - { state: { redirectURL } } - ); + // If URL fragment wasn't specified at all, let's try to capture it. + if (redirectURLFragment === undefined) { + return AuthenticationResult.redirectTo( + `${this.options.basePath.serverBasePath}/internal/security/saml/capture-url-fragment`, + { state: { redirectURL: redirectURLPath, realm: this.realm } } + ); + } + + if (redirectURLFragment.length > 0 && !redirectURLFragment.startsWith('#')) { + this.logger.warn('Redirect URL fragment does not start with `#`.'); + redirectURLFragment = `#${redirectURLFragment}`; + } + + let redirectURL = `${redirectURLPath}${redirectURLFragment}`; + redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURL)); + if (this.maxRedirectURLSize.isLessThan(redirectURLSize)) { + this.logger.warn( + `Max URL size should not exceed ${this.maxRedirectURLSize.toString()} but it was ${redirectURLSize.toString()}. Only URL path is captured.` + ); + redirectURL = redirectURLPath; + } else { + this.logger.debug('Captured redirect URL.'); + } + + return this.authenticateViaHandshake(request, redirectURL); } } diff --git a/x-pack/plugins/security/server/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts index e81d14e8bf9f3..7472adb30307c 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -36,7 +36,7 @@ describe('TokenAuthenticationProvider', () => { let provider: TokenAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'token' }); provider = new TokenAuthenticationProvider(mockOptions); }); @@ -163,6 +163,12 @@ describe('TokenAuthenticationProvider', () => { ).resolves.toEqual(AuthenticationResult.notHandled()); }); + it('does not redirect requests that do not require authentication to the login page.', async () => { + await expect( + provider.authenticate(httpServerMock.createKibanaRequest({ routeAuthRequired: false })) + ).resolves.toEqual(AuthenticationResult.notHandled()); + }); + it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => { await expect( provider.authenticate( @@ -346,6 +352,35 @@ describe('TokenAuthenticationProvider', () => { expect(request.headers).not.toHaveProperty('authorization'); }); + it('does not redirect non-AJAX requests that do not require authentication if token token cannot be refreshed', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: {}, + routeAuthRequired: false, + path: '/some-path', + }); + const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; + const authorization = `Bearer ${tokenPair.accessToken}`; + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + mockOptions.tokens.refresh.mockResolvedValue(null); + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) + ); + + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + it('fails if new access token is rejected after successful refresh', async () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; @@ -386,15 +421,13 @@ describe('TokenAuthenticationProvider', () => { }); describe('`logout` method', () => { - it('returns `redirected` if state is not presented.', async () => { + it('returns `notHandled` if state is not presented.', async () => { const request = httpServerMock.createKibanaRequest(); - await expect(provider.logout(request)).resolves.toEqual( - DeauthenticationResult.redirectTo('/base-path/login?msg=LOGGED_OUT') - ); + await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); await expect(provider.logout(request, null)).resolves.toEqual( - DeauthenticationResult.redirectTo('/base-path/login?msg=LOGGED_OUT') + DeauthenticationResult.notHandled() ); expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index 91808c22c4300..abf4c293c4c53 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -26,6 +26,16 @@ interface ProviderLoginAttempt { */ type ProviderState = TokenPair; +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication and client + // can be redirected to the login page where they can enter username and password. + return canRedirectRequest(request) && request.route.options.authRequired === true; +} + /** * Provider that supports token-based request authentication. */ @@ -102,7 +112,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { // finally, if authentication still can not be handled for this // request/state combination, redirect to the login page if appropriate - if (authenticationResult.notHandled() && canRedirectRequest(request)) { + if (authenticationResult.notHandled() && canStartNewSession(request)) { this.logger.debug('Redirecting request to Login page.'); authenticationResult = AuthenticationResult.redirectTo(this.getLoginPageURL(request)); } @@ -118,16 +128,17 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { public async logout(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); - if (state) { - this.logger.debug('Token-based logout has been initiated by the user.'); - try { - await this.options.tokens.invalidate(state); - } catch (err) { - this.logger.debug(`Failed invalidating user's access token: ${err.message}`); - return DeauthenticationResult.failed(err); - } - } else { + if (!state) { this.logger.debug('There are no access and refresh tokens to invalidate.'); + return DeauthenticationResult.notHandled(); + } + + this.logger.debug('Token-based logout has been initiated by the user.'); + try { + await this.options.tokens.invalidate(state); + } catch (err) { + this.logger.debug(`Failed invalidating user's access token: ${err.message}`); + return DeauthenticationResult.failed(err); } const queryString = request.url.search || `?msg=LOGGED_OUT`; @@ -190,7 +201,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { // If refresh token is no longer valid, then we should clear session and redirect user to the // login page to re-authenticate, or fail if redirect isn't possible. if (refreshedTokenPair === null) { - if (canRedirectRequest(request)) { + if (canStartNewSession(request)) { this.logger.debug('Clearing session since both access and refresh tokens are expired.'); // Set state to `null` to let `Authenticator` know that we want to clear current session. diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 03285184d6572..46a7ee79ee60c 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -6,9 +6,8 @@ jest.mock('crypto', () => ({ randomBytes: jest.fn() })); -import { first } from 'rxjs/operators'; -import { loggingServiceMock, coreMock } from '../../../../src/core/server/mocks'; -import { createConfig$, ConfigSchema } from './config'; +import { loggingServiceMock } from '../../../../src/core/server/mocks'; +import { createConfig, ConfigSchema } from './config'; describe('config schema', () => { it('generates proper defaults', () => { @@ -25,9 +24,22 @@ describe('config schema', () => { "apikey", ], }, - "providers": Array [ - "basic", - ], + "providers": Object { + "basic": Object { + "basic": Object { + "description": undefined, + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + "kerberos": undefined, + "oidc": undefined, + "pki": undefined, + "saml": undefined, + "token": undefined, + }, + "selector": Object {}, }, "cookieName": "sid", "enabled": true, @@ -54,9 +66,22 @@ describe('config schema', () => { "apikey", ], }, - "providers": Array [ - "basic", - ], + "providers": Object { + "basic": Object { + "basic": Object { + "description": undefined, + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + "kerberos": undefined, + "oidc": undefined, + "pki": undefined, + "saml": undefined, + "token": undefined, + }, + "selector": Object {}, }, "cookieName": "sid", "enabled": true, @@ -83,9 +108,22 @@ describe('config schema', () => { "apikey", ], }, - "providers": Array [ - "basic", - ], + "providers": Object { + "basic": Object { + "basic": Object { + "description": undefined, + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + "kerberos": undefined, + "oidc": undefined, + "pki": undefined, + "saml": undefined, + "token": undefined, + }, + "selector": Object {}, }, "cookieName": "sid", "enabled": true, @@ -148,6 +186,7 @@ describe('config schema', () => { "providers": Array [ "oidc", ], + "selector": Object {}, } `); }); @@ -181,6 +220,7 @@ describe('config schema', () => { "oidc", "basic", ], + "selector": Object {}, } `); }); @@ -228,6 +268,7 @@ describe('config schema', () => { }, "realm": "realm-1", }, + "selector": Object {}, } `); }); @@ -305,27 +346,476 @@ describe('config schema', () => { `); }); }); + + describe('authc.providers (extended format)', () => { + describe('`basic` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { basic: { basic1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.basic.basic1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('does not allow custom description', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { basic: { basic1: { order: 0, description: 'Some description' } } }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.basic.basic1.description]: \`basic\` provider does not support custom description." +`); + }); + + it('cannot be hidden from selector', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { basic: { basic1: { order: 0, showInSelector: false } } }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.basic.basic1.showInSelector]: \`basic\` provider only supports \`true\` in \`showInSelector\`." +`); + }); + + it('can have only provider of this type', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { basic: { basic1: { order: 0 }, basic2: { order: 1 } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.basic]: Only one \\"basic\\" provider can be configured." +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { providers: { basic: { basic1: { order: 0 } } } }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "basic": Object { + "basic1": Object { + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + } + `); + }); + }); + + describe('`token` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { token: { token1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.token.token1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('does not allow custom description', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { token: { token1: { order: 0, description: 'Some description' } } }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.token.token1.description]: \`token\` provider does not support custom description." +`); + }); + + it('cannot be hidden from selector', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { token: { token1: { order: 0, showInSelector: false } } }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.token.token1.showInSelector]: \`token\` provider only supports \`true\` in \`showInSelector\`." +`); + }); + + it('can have only provider of this type', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { token: { token1: { order: 0 }, token2: { order: 1 } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.token]: Only one \\"token\\" provider can be configured." +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { providers: { token: { token1: { order: 0 } } } }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "token": Object { + "token1": Object { + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + } + `); + }); + }); + + describe('`pki` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { pki: { pki1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.pki.pki1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('can have only provider of this type', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { pki: { pki1: { order: 0 }, pki2: { order: 1 } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.pki]: Only one \\"pki\\" provider can be configured." +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { providers: { pki: { pki1: { order: 0 } } } }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "pki": Object { + "pki1": Object { + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + } + `); + }); + }); + + describe('`kerberos` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { kerberos: { kerberos1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.kerberos.kerberos1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('can have only provider of this type', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { kerberos: { kerberos1: { order: 0 }, kerberos2: { order: 1 } } }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.kerberos]: Only one \\"kerberos\\" provider can be configured." +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { providers: { kerberos: { kerberos1: { order: 0 } } } }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "kerberos": Object { + "kerberos1": Object { + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + } + `); + }); + }); + + describe('`oidc` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { oidc: { oidc1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.oidc.oidc1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('requires `realm`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { oidc: { oidc1: { order: 0 } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.oidc.oidc1.realm]: expected value of type [string] but got [undefined]" +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { + providers: { + oidc: { oidc1: { order: 0, realm: 'oidc1' }, oidc2: { order: 1, realm: 'oidc2' } }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "oidc": Object { + "oidc1": Object { + "enabled": true, + "order": 0, + "realm": "oidc1", + "showInSelector": true, + }, + "oidc2": Object { + "enabled": true, + "order": 1, + "realm": "oidc2", + "showInSelector": true, + }, + }, + } + `); + }); + }); + + describe('`saml` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { saml: { saml1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.saml.saml1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('requires `realm`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { saml: { saml1: { order: 0 } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.saml.saml1.realm]: expected value of type [string] but got [undefined]" +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { + providers: { + saml: { + saml1: { order: 0, realm: 'saml1' }, + saml2: { order: 1, realm: 'saml2', maxRedirectURLSize: '1kb' }, + }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "saml": Object { + "saml1": Object { + "enabled": true, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 2048, + }, + "order": 0, + "realm": "saml1", + "showInSelector": true, + }, + "saml2": Object { + "enabled": true, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 1024, + }, + "order": 1, + "realm": "saml2", + "showInSelector": true, + }, + }, + } + `); + }); + }); + + it('`name` should be unique across all provider types', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { + basic: { provider1: { order: 0 } }, + saml: { + provider2: { order: 1, realm: 'saml1' }, + provider1: { order: 2, realm: 'saml2' }, + }, + }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1]: Found multiple providers configured with the same name \\"provider1\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider1]" +`); + }); + + it('`order` should be unique across all provider types', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { + basic: { provider1: { order: 0 } }, + saml: { + provider2: { order: 0, realm: 'saml1' }, + provider3: { order: 2, realm: 'saml2' }, + }, + }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1]: Found multiple providers configured with the same order \\"0\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider2]" +`); + }); + + it('can be successfully validated with multiple providers ignoring uniqueness violations in disabled ones', () => { + expect( + ConfigSchema.validate({ + authc: { + providers: { + basic: { basic1: { order: 0 }, basic2: { enabled: false, order: 1 } }, + saml: { + saml1: { order: 1, realm: 'saml1' }, + saml2: { order: 2, realm: 'saml2' }, + basic1: { order: 3, realm: 'saml3', enabled: false }, + }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "basic": Object { + "basic1": Object { + "enabled": true, + "order": 0, + "showInSelector": true, + }, + "basic2": Object { + "enabled": false, + "order": 1, + "showInSelector": true, + }, + }, + "saml": Object { + "basic1": Object { + "enabled": false, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 2048, + }, + "order": 3, + "realm": "saml3", + "showInSelector": true, + }, + "saml1": Object { + "enabled": true, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 2048, + }, + "order": 1, + "realm": "saml1", + "showInSelector": true, + }, + "saml2": Object { + "enabled": true, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 2048, + }, + "order": 2, + "realm": "saml2", + "showInSelector": true, + }, + }, + } + `); + }); + }); }); -describe('createConfig$()', () => { - const mockAndCreateConfig = async (isTLSEnabled: boolean, value = {}, context?: any) => { - const contextMock = coreMock.createPluginInitializerContext( - // we must use validate to avoid errors in `createConfig$` - ConfigSchema.validate(value, context) - ); - return await createConfig$(contextMock, isTLSEnabled) - .pipe(first()) - .toPromise() - .then(config => ({ contextMock, config })); - }; +describe('createConfig()', () => { it('should log a warning and set xpack.security.encryptionKey if not set', async () => { const mockRandomBytes = jest.requireMock('crypto').randomBytes; mockRandomBytes.mockReturnValue('ab'.repeat(16)); - const { contextMock, config } = await mockAndCreateConfig(true, {}, { dist: true }); + const logger = loggingServiceMock.create().get(); + const config = createConfig(ConfigSchema.validate({}, { dist: true }), logger, { + isTLSEnabled: true, + }); expect(config.encryptionKey).toEqual('ab'.repeat(16)); - expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` + expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on restart, please set xpack.security.encryptionKey in kibana.yml", @@ -335,10 +825,11 @@ describe('createConfig$()', () => { }); it('should log a warning if SSL is not configured', async () => { - const { contextMock, config } = await mockAndCreateConfig(false, {}); + const logger = loggingServiceMock.create().get(); + const config = createConfig(ConfigSchema.validate({}), logger, { isTLSEnabled: false }); expect(config.secureCookies).toEqual(false); - expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` + expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Session cookies will be transmitted over insecure connections. This is not recommended.", @@ -348,10 +839,13 @@ describe('createConfig$()', () => { }); it('should log a warning if SSL is not configured yet secure cookies are being used', async () => { - const { contextMock, config } = await mockAndCreateConfig(false, { secureCookies: true }); + const logger = loggingServiceMock.create().get(); + const config = createConfig(ConfigSchema.validate({ secureCookies: true }), logger, { + isTLSEnabled: false, + }); expect(config.secureCookies).toEqual(true); - expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` + expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to function properly.", @@ -361,9 +855,210 @@ describe('createConfig$()', () => { }); it('should set xpack.security.secureCookies if SSL is configured', async () => { - const { contextMock, config } = await mockAndCreateConfig(true, {}); + const logger = loggingServiceMock.create().get(); + const config = createConfig(ConfigSchema.validate({}), logger, { isTLSEnabled: true }); expect(config.secureCookies).toEqual(true); - expect(loggingServiceMock.collect(contextMock.logger).warn).toEqual([]); + expect(loggingServiceMock.collect(logger).warn).toEqual([]); + }); + + it('transforms legacy `authc.providers` into new format', () => { + const logger = loggingServiceMock.create().get(); + + expect( + createConfig( + ConfigSchema.validate({ + authc: { + providers: ['saml', 'basic'], + saml: { realm: 'saml-realm' }, + }, + }), + logger, + { isTLSEnabled: true } + ).authc + ).toMatchInlineSnapshot(` + Object { + "http": Object { + "autoSchemesEnabled": true, + "enabled": true, + "schemes": Array [ + "apikey", + ], + }, + "providers": Object { + "basic": Object { + "basic": Object { + "enabled": true, + "order": 1, + "showInSelector": true, + }, + }, + "saml": Object { + "saml": Object { + "enabled": true, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 2048, + }, + "order": 0, + "realm": "saml-realm", + "showInSelector": true, + }, + }, + }, + "selector": Object { + "enabled": false, + }, + "sortedProviders": Array [ + Object { + "name": "saml", + "options": Object { + "description": undefined, + "order": 0, + "showInSelector": true, + }, + "type": "saml", + }, + Object { + "name": "basic", + "options": Object { + "description": undefined, + "order": 1, + "showInSelector": true, + }, + "type": "basic", + }, + ], + } + `); + }); + + it('does not automatically set `authc.selector.enabled` to `true` if legacy `authc.providers` format is used', () => { + expect( + createConfig( + ConfigSchema.validate({ + authc: { providers: ['saml', 'basic'], saml: { realm: 'saml-realm' } }, + }), + loggingServiceMock.create().get(), + { isTLSEnabled: true } + ).authc.selector.enabled + ).toBe(false); + + // But keep it as `true` if it's explicitly set. + expect( + createConfig( + ConfigSchema.validate({ + authc: { + selector: { enabled: true }, + providers: ['saml', 'basic'], + saml: { realm: 'saml-realm' }, + }, + }), + loggingServiceMock.create().get(), + { isTLSEnabled: true } + ).authc.selector.enabled + ).toBe(true); + }); + + it('does not automatically set `authc.selector.enabled` to `true` if less than 2 providers must be shown there', () => { + expect( + createConfig( + ConfigSchema.validate({ + authc: { + providers: { + basic: { basic1: { order: 0 } }, + saml: { + saml1: { order: 1, realm: 'saml1', showInSelector: false }, + saml2: { enabled: false, order: 2, realm: 'saml2' }, + }, + }, + }, + }), + loggingServiceMock.create().get(), + { isTLSEnabled: true } + ).authc.selector.enabled + ).toBe(false); + }); + + it('automatically set `authc.selector.enabled` to `true` if more than 1 provider must be shown there', () => { + expect( + createConfig( + ConfigSchema.validate({ + authc: { + providers: { + basic: { basic1: { order: 0 } }, + saml: { saml1: { order: 1, realm: 'saml1' }, saml2: { order: 2, realm: 'saml2' } }, + }, + }, + }), + loggingServiceMock.create().get(), + { isTLSEnabled: true } + ).authc.selector.enabled + ).toBe(true); + }); + + it('correctly sorts providers based on the `order`', () => { + expect( + createConfig( + ConfigSchema.validate({ + authc: { + providers: { + basic: { basic1: { order: 3 } }, + saml: { saml1: { order: 2, realm: 'saml1' }, saml2: { order: 1, realm: 'saml2' } }, + oidc: { oidc1: { order: 0, realm: 'oidc1' }, oidc2: { order: 4, realm: 'oidc2' } }, + }, + }, + }), + loggingServiceMock.create().get(), + { isTLSEnabled: true } + ).authc.sortedProviders + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "oidc1", + "options": Object { + "description": undefined, + "order": 0, + "showInSelector": true, + }, + "type": "oidc", + }, + Object { + "name": "saml2", + "options": Object { + "description": undefined, + "order": 1, + "showInSelector": true, + }, + "type": "saml", + }, + Object { + "name": "saml1", + "options": Object { + "description": undefined, + "order": 2, + "showInSelector": true, + }, + "type": "saml", + }, + Object { + "name": "basic1", + "options": Object { + "description": undefined, + "order": 3, + "showInSelector": true, + }, + "type": "basic", + }, + Object { + "name": "oidc2", + "options": Object { + "description": undefined, + "order": 4, + "showInSelector": true, + }, + "type": "oidc", + }, + ] + `); }); }); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 2345249e94bc8..97ff7d00a4336 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -5,14 +5,10 @@ */ import crypto from 'crypto'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; import { schema, Type, TypeOf } from '@kbn/config-schema'; -import { PluginInitializerContext } from '../../../../src/core/server'; +import { Logger } from '../../../../src/core/server'; -export type ConfigType = ReturnType extends Observable - ? P - : ReturnType; +export type ConfigType = ReturnType; const providerOptionsSchema = (providerType: string, optionsSchema: Type) => schema.conditional( @@ -24,6 +20,114 @@ const providerOptionsSchema = (providerType: string, optionsSchema: Type) = schema.never() ); +type ProvidersCommonConfigType = Record< + 'enabled' | 'showInSelector' | 'order' | 'description', + Type +>; +function getCommonProviderSchemaProperties(overrides: Partial = {}) { + return { + enabled: schema.boolean({ defaultValue: true }), + showInSelector: schema.boolean({ defaultValue: true }), + order: schema.number({ min: 0 }), + description: schema.maybe(schema.string()), + ...overrides, + }; +} + +function getUniqueProviderSchema( + providerType: string, + overrides?: Partial +) { + return schema.maybe( + schema.recordOf(schema.string(), schema.object(getCommonProviderSchemaProperties(overrides)), { + validate(config) { + if (Object.values(config).filter(provider => provider.enabled).length > 1) { + return `Only one "${providerType}" provider can be configured.`; + } + }, + }) + ); +} + +type ProvidersConfigType = TypeOf; +const providersConfigSchema = schema.object( + { + basic: getUniqueProviderSchema('basic', { + description: schema.maybe( + schema.any({ + validate: () => '`basic` provider does not support custom description.', + }) + ), + showInSelector: schema.boolean({ + defaultValue: true, + validate: value => { + if (!value) { + return '`basic` provider only supports `true` in `showInSelector`.'; + } + }, + }), + }), + token: getUniqueProviderSchema('token', { + description: schema.maybe( + schema.any({ + validate: () => '`token` provider does not support custom description.', + }) + ), + showInSelector: schema.boolean({ + defaultValue: true, + validate: value => { + if (!value) { + return '`token` provider only supports `true` in `showInSelector`.'; + } + }, + }), + }), + kerberos: getUniqueProviderSchema('kerberos'), + pki: getUniqueProviderSchema('pki'), + saml: schema.maybe( + schema.recordOf( + schema.string(), + schema.object({ + ...getCommonProviderSchemaProperties(), + realm: schema.string(), + maxRedirectURLSize: schema.byteSize({ defaultValue: '2kb' }), + }) + ) + ), + oidc: schema.maybe( + schema.recordOf( + schema.string(), + schema.object({ ...getCommonProviderSchemaProperties(), realm: schema.string() }) + ) + ), + }, + { + validate(config) { + const checks = { sameOrder: new Map(), sameName: new Map() }; + for (const [providerType, providerGroup] of Object.entries(config)) { + for (const [providerName, { enabled, order }] of Object.entries(providerGroup ?? {})) { + if (!enabled) { + continue; + } + + const providerPath = `xpack.security.authc.providers.${providerType}.${providerName}`; + const providerWithSameOrderPath = checks.sameOrder.get(order); + if (providerWithSameOrderPath) { + return `Found multiple providers configured with the same order "${order}": [${providerWithSameOrderPath}, ${providerPath}]`; + } + checks.sameOrder.set(order, providerPath); + + const providerWithSameName = checks.sameName.get(providerName); + if (providerWithSameName) { + return `Found multiple providers configured with the same name "${providerName}": [${providerWithSameName}, ${providerPath}]`; + } + checks.sameName.set(providerName, providerPath); + } + } + }, + } +); + export const ConfigSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), loginAssistanceMessage: schema.string({ defaultValue: '' }), @@ -40,7 +144,17 @@ export const ConfigSchema = schema.object({ }), secureCookies: schema.boolean({ defaultValue: false }), authc: schema.object({ - providers: schema.arrayOf(schema.string(), { defaultValue: ['basic'], minSize: 1 }), + selector: schema.object({ enabled: schema.maybe(schema.boolean()) }), + providers: schema.oneOf([schema.arrayOf(schema.string()), providersConfigSchema], { + defaultValue: { + basic: { basic: { enabled: true, showInSelector: true, order: 0, description: undefined } }, + token: undefined, + saml: undefined, + oidc: undefined, + pki: undefined, + kerberos: undefined, + }, + }), oidc: providerOptionsSchema('oidc', schema.object({ realm: schema.string() })), saml: providerOptionsSchema( 'saml', @@ -60,42 +174,96 @@ export const ConfigSchema = schema.object({ }), }); -export function createConfig$(context: PluginInitializerContext, isTLSEnabled: boolean) { - return context.config.create>().pipe( - map(config => { - const logger = context.logger.get('config'); +export function createConfig( + config: TypeOf, + logger: Logger, + { isTLSEnabled }: { isTLSEnabled: boolean } +) { + let encryptionKey = config.encryptionKey; + if (encryptionKey === undefined) { + logger.warn( + 'Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on ' + + 'restart, please set xpack.security.encryptionKey in kibana.yml' + ); - let encryptionKey = config.encryptionKey; - if (encryptionKey === undefined) { - logger.warn( - 'Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on ' + - 'restart, please set xpack.security.encryptionKey in kibana.yml' - ); + encryptionKey = crypto.randomBytes(16).toString('hex'); + } - encryptionKey = crypto.randomBytes(16).toString('hex'); - } + let secureCookies = config.secureCookies; + if (!isTLSEnabled) { + if (secureCookies) { + logger.warn( + 'Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to ' + + 'function properly.' + ); + } else { + logger.warn( + 'Session cookies will be transmitted over insecure connections. This is not recommended.' + ); + } + } else if (!secureCookies) { + secureCookies = true; + } - let secureCookies = config.secureCookies; - if (!isTLSEnabled) { - if (secureCookies) { - logger.warn( - 'Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to ' + - 'function properly.' - ); - } else { - logger.warn( - 'Session cookies will be transmitted over insecure connections. This is not recommended.' - ); - } - } else if (!secureCookies) { - secureCookies = true; + const isUsingLegacyProvidersFormat = Array.isArray(config.authc.providers); + const providers = (isUsingLegacyProvidersFormat + ? [...new Set(config.authc.providers as Array)].reduce( + (legacyProviders, providerType, order) => { + legacyProviders[providerType] = { + [providerType]: + providerType === 'saml' || providerType === 'oidc' + ? { enabled: true, showInSelector: true, order, ...config.authc[providerType] } + : { enabled: true, showInSelector: true, order }, + }; + return legacyProviders; + }, + {} as Record + ) + : config.authc.providers) as ProvidersConfigType; + + // Remove disabled providers and sort the rest. + const sortedProviders: Array<{ + type: keyof ProvidersConfigType; + name: string; + options: { order: number; showInSelector: boolean; description?: string }; + }> = []; + for (const [type, providerGroup] of Object.entries(providers)) { + for (const [name, { enabled, showInSelector, order, description }] of Object.entries( + providerGroup ?? {} + )) { + if (!enabled) { + delete providerGroup![name]; + } else { + sortedProviders.push({ + type: type as any, + name, + options: { order, showInSelector, description }, + }); } + } + } - return { - ...config, - encryptionKey, - secureCookies, - }; - }) + sortedProviders.sort(({ options: { order: orderA } }, { options: { order: orderB } }) => + orderA < orderB ? -1 : orderA > orderB ? 1 : 0 ); + + // We enable Login Selector by default if a) it's not explicitly disabled, b) new config + // format of providers is used and c) we have more than one provider enabled. + const isLoginSelectorEnabled = + typeof config.authc.selector.enabled === 'boolean' + ? config.authc.selector.enabled + : !isUsingLegacyProvidersFormat && + sortedProviders.filter(provider => provider.options.showInSelector).length > 1; + + return { + ...config, + authc: { + selector: { ...config.authc.selector, enabled: isLoginSelectorEnabled }, + providers, + sortedProviders: Object.freeze(sortedProviders), + http: config.authc.http, + }, + encryptionKey, + secureCookies, + }; } diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index 0b17f0554fac8..caeb06e6f3153 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -23,6 +23,8 @@ export { CreateAPIKeyResult, InvalidateAPIKeyParams, InvalidateAPIKeyResult, + SAMLLogin, + OIDCLogin, } from './authentication'; export { SecurityPluginSetup }; export { AuthenticatedUser } from '../common/model'; @@ -32,11 +34,29 @@ export const config: PluginConfigDescriptor> = { deprecations: ({ rename, unused }) => [ rename('sessionTimeout', 'session.idleTimeout'), unused('authorization.legacyFallback.enabled'), + // Deprecation warning for the old array-based format of `xpack.security.authc.providers`. (settings, fromPath, log) => { - const hasProvider = (provider: string) => - settings?.xpack?.security?.authc?.providers?.includes(provider) ?? false; + if (Array.isArray(settings?.xpack?.security?.authc?.providers)) { + log( + 'Defining `xpack.security.authc.providers` as an array of provider types is deprecated. Use extended `object` format instead.' + ); + } + + return settings; + }, + (settings, fromPath, log) => { + const hasProviderType = (providerType: string) => { + const providers = settings?.xpack?.security?.authc?.providers; + if (Array.isArray(providers)) { + return providers.includes(providerType); + } + + return Object.values(providers?.[providerType] || {}).some( + provider => (provider as { enabled: boolean | undefined })?.enabled !== false + ); + }; - if (hasProvider('basic') && hasProvider('token')) { + if (hasProviderType('basic') && hasProviderType('token')) { log( 'Enabling both `basic` and `token` authentication providers in `xpack.security.authc.providers` is deprecated. Login page will only use `token` provider.' ); diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index a011f7e7be11e..a23c826b32fbd 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -26,6 +26,7 @@ describe('Security Plugin', () => { lifespan: null, }, authc: { + selector: { enabled: false }, providers: ['saml', 'token'], saml: { realm: 'saml1', maxRedirectURLSize: new ByteSizeValue(2048) }, http: { enabled: true, autoSchemesEnabled: true, schemes: ['apikey'] }, @@ -49,9 +50,6 @@ describe('Security Plugin', () => { await expect(plugin.setup(mockCoreSetup, mockDependencies)).resolves.toMatchInlineSnapshot(` Object { "__legacyCompat": Object { - "config": Object { - "secureCookies": true, - }, "license": Object { "features$": Observable { "_isScalar": false, @@ -78,7 +76,7 @@ describe('Security Plugin', () => { "invalidateAPIKey": [Function], "invalidateAPIKeyAsInternalUser": [Function], "isAuthenticated": [Function], - "isProviderEnabled": [Function], + "isProviderTypeEnabled": [Function], "login": [Function], "logout": [Function], }, diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 13300ee55eba0..032d231fe798f 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -5,13 +5,13 @@ */ import { combineLatest } from 'rxjs'; -import { first } from 'rxjs/operators'; +import { first, map } from 'rxjs/operators'; +import { TypeOf } from '@kbn/config-schema'; import { ICustomClusterClient, CoreSetup, Logger, PluginInitializerContext, - RecursiveReadonly, } from '../../../../src/core/server'; import { deepFreeze } from '../../../../src/core/utils'; import { SpacesPluginSetup } from '../../spaces/server'; @@ -20,7 +20,7 @@ import { LicensingPluginSetup } from '../../licensing/server'; import { Authentication, setupAuthentication } from './authentication'; import { Authorization, setupAuthorization } from './authorization'; -import { createConfig$ } from './config'; +import { ConfigSchema, createConfig } from './config'; import { defineRoutes } from './routes'; import { SecurityLicenseService, SecurityLicense } from '../common/licensing'; import { setupSavedObjects } from './saved_objects'; @@ -65,7 +65,6 @@ export interface SecurityPluginSetup { registerLegacyAPI: (legacyAPI: LegacyAPI) => void; registerPrivilegesWithCluster: () => void; license: SecurityLicense; - config: RecursiveReadonly<{ secureCookies: boolean }>; }; } @@ -106,7 +105,13 @@ export class Plugin { public async setup(core: CoreSetup, { features, licensing }: PluginSetupDependencies) { const [config, legacyConfig] = await combineLatest([ - createConfig$(this.initializerContext, core.http.isTlsEnabled), + this.initializerContext.config.create>().pipe( + map(rawConfig => + createConfig(rawConfig, this.initializerContext.logger.get('config'), { + isTLSEnabled: core.http.isTlsEnabled, + }) + ) + ), this.initializerContext.config.legacy.globalConfig$, ]) .pipe(first()) @@ -183,11 +188,6 @@ export class Plugin { registerPrivilegesWithCluster: async () => await authz.registerPrivilegesWithCluster(), license, - - // We should stop exposing this config as soon as only new platform plugin consumes it. - // This is only currently required because we use legacy code to inject this as metadata - // for consumption by public code in the new platform. - config: { secureCookies: config.secureCookies }, }, }); } diff --git a/x-pack/plugins/security/server/routes/authentication/basic.test.ts b/x-pack/plugins/security/server/routes/authentication/basic.test.ts index cd3b871671551..3c114978f26d2 100644 --- a/x-pack/plugins/security/server/routes/authentication/basic.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/basic.test.ts @@ -29,7 +29,7 @@ describe('Basic authentication routes', () => { router = routeParamsMock.router; authc = routeParamsMock.authc; - authc.isProviderEnabled.mockImplementation(provider => provider === 'basic'); + authc.isProviderTypeEnabled.mockImplementation(provider => provider === 'basic'); mockContext = ({ licensing: { @@ -108,7 +108,7 @@ describe('Basic authentication routes', () => { expect(response.status).toBe(500); expect(response.payload).toEqual(unhandledException); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'basic', + provider: { type: 'basic' }, value: { username: 'user', password: 'password' }, }); }); @@ -122,7 +122,7 @@ describe('Basic authentication routes', () => { expect(response.status).toBe(401); expect(response.payload).toEqual(failureReason); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'basic', + provider: { type: 'basic' }, value: { username: 'user', password: 'password' }, }); }); @@ -135,7 +135,7 @@ describe('Basic authentication routes', () => { expect(response.status).toBe(401); expect(response.payload).toEqual('Unauthorized'); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'basic', + provider: { type: 'basic' }, value: { username: 'user', password: 'password' }, }); }); @@ -149,14 +149,14 @@ describe('Basic authentication routes', () => { expect(response.status).toBe(204); expect(response.payload).toBeUndefined(); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'basic', + provider: { type: 'basic' }, value: { username: 'user', password: 'password' }, }); }); it('prefers `token` authentication provider if it is enabled', async () => { authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); - authc.isProviderEnabled.mockImplementation( + authc.isProviderTypeEnabled.mockImplementation( provider => provider === 'token' || provider === 'basic' ); @@ -165,7 +165,7 @@ describe('Basic authentication routes', () => { expect(response.status).toBe(204); expect(response.payload).toBeUndefined(); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'token', + provider: { type: 'token' }, value: { username: 'user', password: 'password' }, }); }); diff --git a/x-pack/plugins/security/server/routes/authentication/basic.ts b/x-pack/plugins/security/server/routes/authentication/basic.ts index db36e45fc07e8..ccc6a8df24d6e 100644 --- a/x-pack/plugins/security/server/routes/authentication/basic.ts +++ b/x-pack/plugins/security/server/routes/authentication/basic.ts @@ -26,9 +26,10 @@ export function defineBasicRoutes({ router, authc, config }: RouteDefinitionPara }, createLicensedRouteHandler(async (context, request, response) => { // We should prefer `token` over `basic` if possible. - const loginAttempt = authc.isProviderEnabled('token') - ? { provider: 'token', value: request.body } - : { provider: 'basic', value: request.body }; + const loginAttempt = { + provider: { type: authc.isProviderTypeEnabled('token') ? 'token' : 'basic' }, + value: request.body, + }; try { const authenticationResult = await authc.login(request, loginAttempt); diff --git a/x-pack/plugins/security/server/routes/authentication/common.test.ts b/x-pack/plugins/security/server/routes/authentication/common.test.ts index b611ffffee935..e2f9593bc09ee 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.test.ts @@ -13,7 +13,13 @@ import { RouteConfig, } from '../../../../../../src/core/server'; import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; -import { Authentication, DeauthenticationResult } from '../../authentication'; +import { + Authentication, + AuthenticationResult, + DeauthenticationResult, + OIDCLogin, + SAMLLogin, +} from '../../authentication'; import { defineCommonRoutes } from './common'; import { httpServerMock } from '../../../../../../src/core/server/mocks'; @@ -172,4 +178,260 @@ describe('Common authentication routes', () => { expect(authc.getCurrentUser).toHaveBeenCalledWith(mockRequest); }); }); + + describe('login_with', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [acsRouteConfig, acsRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => path === '/internal/security/login_with' + )!; + + routeConfig = acsRouteConfig; + routeHandler = acsRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toEqual({ authRequired: false }); + expect(routeConfig.validate).toEqual({ + body: expect.any(Type), + query: undefined, + params: undefined, + }); + + const bodyValidator = (routeConfig.validate as any).body as Type; + expect( + bodyValidator.validate({ + providerType: 'saml', + providerName: 'saml1', + currentURL: '/some-url', + }) + ).toEqual({ + providerType: 'saml', + providerName: 'saml1', + currentURL: '/some-url', + }); + + expect( + bodyValidator.validate({ + providerType: 'saml', + providerName: 'saml1', + currentURL: '', + }) + ).toEqual({ + providerType: 'saml', + providerName: 'saml1', + currentURL: '', + }); + + expect(() => bodyValidator.validate({})).toThrowErrorMatchingInlineSnapshot( + `"[providerType]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ providerType: 'saml' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[providerName]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ providerType: 'saml', providerName: 'saml1' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[currentURL]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'saml', + providerName: 'saml1', + currentURL: '/some-url', + UnknownArg: 'arg', + }) + ).toThrowErrorMatchingInlineSnapshot(`"[UnknownArg]: definition for this key is missing"`); + }); + + it('returns 500 if login throws unhandled exception.', async () => { + const unhandledException = new Error('Something went wrong.'); + authc.login.mockRejectedValue(unhandledException); + + const request = httpServerMock.createKibanaRequest({ + body: { providerType: 'saml', providerName: 'saml1', currentURL: '/some-url' }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 500, + payload: 'Internal Error', + options: {}, + }); + }); + + it('returns 401 if login fails.', async () => { + const failureReason = new Error('Something went wrong.'); + authc.login.mockResolvedValue( + AuthenticationResult.failed(failureReason, { + authResponseHeaders: { 'WWW-Something': 'something' }, + }) + ); + + const request = httpServerMock.createKibanaRequest({ + body: { providerType: 'saml', providerName: 'saml1', currentURL: '/some-url' }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 401, + payload: failureReason, + options: { body: failureReason, headers: { 'WWW-Something': 'something' } }, + }); + }); + + it('returns 401 if login is not handled.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.notHandled()); + + const request = httpServerMock.createKibanaRequest({ + body: { providerType: 'saml', providerName: 'saml1', currentURL: '/some-url' }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 401, + payload: 'Unauthorized', + options: {}, + }); + }); + + it('returns redirect location from authentication result if any.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); + + const request = httpServerMock.createKibanaRequest({ + body: { providerType: 'saml', providerName: 'saml1', currentURL: '/some-url' }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: 'http://redirect-to/path' }, + options: { body: { location: 'http://redirect-to/path' } }, + }); + }); + + it('returns location extracted from `next` parameter if authentication result does not specify any.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); + + const request = httpServerMock.createKibanaRequest({ + body: { + providerType: 'saml', + providerName: 'saml1', + currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav', + }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: '/mock-server-basepath/some-url#/app/nav' }, + options: { body: { location: '/mock-server-basepath/some-url#/app/nav' } }, + }); + }); + + it('returns base path if location cannot be extracted from `currentURL` parameter and authentication result does not specify any.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); + + const invalidCurrentURLs = [ + 'https://kibana.com/?next=https://evil.com/mock-server-basepath/some-url#/app/nav', + 'https://kibana.com/?next=https://kibana.com:9000/mock-server-basepath/some-url#/app/nav', + 'https://kibana.com/?next=kibana.com/mock-server-basepath/some-url#/app/nav', + 'https://kibana.com/?next=//mock-server-basepath/some-url#/app/nav', + 'https://kibana.com/?next=../mock-server-basepath/some-url#/app/nav', + 'https://kibana.com/?next=/some-url#/app/nav', + '', + ]; + + for (const currentURL of invalidCurrentURLs) { + const request = httpServerMock.createKibanaRequest({ + body: { providerType: 'saml', providerName: 'saml1', currentURL }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: '/mock-server-basepath/' }, + options: { body: { location: '/mock-server-basepath/' } }, + }); + } + }); + + it('correctly performs SAML login.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); + + const request = httpServerMock.createKibanaRequest({ + body: { + providerType: 'saml', + providerName: 'saml1', + currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav', + }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: 'http://redirect-to/path' }, + options: { body: { location: 'http://redirect-to/path' } }, + }); + + expect(authc.login).toHaveBeenCalledTimes(1); + expect(authc.login).toHaveBeenCalledWith(request, { + provider: { name: 'saml1' }, + value: { + type: SAMLLogin.LoginInitiatedByUser, + redirectURLPath: '/mock-server-basepath/some-url', + redirectURLFragment: '#/app/nav', + }, + }); + }); + + it('correctly performs OIDC login.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); + + const request = httpServerMock.createKibanaRequest({ + body: { + providerType: 'oidc', + providerName: 'oidc1', + currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav', + }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: 'http://redirect-to/path' }, + options: { body: { location: 'http://redirect-to/path' } }, + }); + + expect(authc.login).toHaveBeenCalledTimes(1); + expect(authc.login).toHaveBeenCalledWith(request, { + provider: { name: 'oidc1' }, + value: { + type: OIDCLogin.LoginInitiatedByUser, + redirectURLPath: '/mock-server-basepath/some-url', + }, + }); + }); + + it('correctly performs generic login.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); + + const request = httpServerMock.createKibanaRequest({ + body: { + providerType: 'some-type', + providerName: 'some-name', + currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav', + }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: 'http://redirect-to/path' }, + options: { body: { location: 'http://redirect-to/path' } }, + }); + + expect(authc.login).toHaveBeenCalledTimes(1); + expect(authc.login).toHaveBeenCalledWith(request, { + provider: { name: 'some-name' }, + }); + }); + }); }); diff --git a/x-pack/plugins/security/server/routes/authentication/common.ts b/x-pack/plugins/security/server/routes/authentication/common.ts index 19d197b63f540..abab67c9cd1d2 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.ts @@ -5,9 +5,14 @@ */ import { schema } from '@kbn/config-schema'; -import { canRedirectRequest } from '../../authentication'; +import { parseNext } from '../../../common/parse_next'; +import { canRedirectRequest, OIDCLogin, SAMLLogin } from '../../authentication'; import { wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { + OIDCAuthenticationProvider, + SAMLAuthenticationProvider, +} from '../../authentication/providers'; import { RouteDefinitionParams } from '..'; /** @@ -71,4 +76,63 @@ export function defineCommonRoutes({ router, authc, basePath, logger }: RouteDef }) ); } + + function getLoginAttemptForProviderType(providerType: string, redirectURL: string) { + const [redirectURLPath] = redirectURL.split('#'); + const redirectURLFragment = + redirectURL.length > redirectURLPath.length + ? redirectURL.substring(redirectURLPath.length) + : ''; + + if (providerType === SAMLAuthenticationProvider.type) { + return { type: SAMLLogin.LoginInitiatedByUser, redirectURLPath, redirectURLFragment }; + } + + if (providerType === OIDCAuthenticationProvider.type) { + return { type: OIDCLogin.LoginInitiatedByUser, redirectURLPath }; + } + + return undefined; + } + + router.post( + { + path: '/internal/security/login_with', + validate: { + body: schema.object({ + providerType: schema.string(), + providerName: schema.string(), + currentURL: schema.string(), + }), + }, + options: { authRequired: false }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const { providerType, providerName, currentURL } = request.body; + logger.info(`Logging in with provider "${providerName}" (${providerType})`); + + const redirectURL = parseNext(currentURL, basePath.serverBasePath); + try { + const authenticationResult = await authc.login(request, { + provider: { name: providerName }, + value: getLoginAttemptForProviderType(providerType, redirectURL), + }); + + if (authenticationResult.redirected() || authenticationResult.succeeded()) { + return response.ok({ + body: { location: authenticationResult.redirectURL || redirectURL }, + headers: authenticationResult.authResponseHeaders, + }); + } + + return response.unauthorized({ + body: authenticationResult.error, + headers: authenticationResult.authResponseHeaders, + }); + } catch (err) { + logger.error(err); + return response.internalError(); + } + }) + ); } diff --git a/x-pack/plugins/security/server/routes/authentication/index.ts b/x-pack/plugins/security/server/routes/authentication/index.ts index a774edfb4ab2c..f3082b089faf5 100644 --- a/x-pack/plugins/security/server/routes/authentication/index.ts +++ b/x-pack/plugins/security/server/routes/authentication/index.ts @@ -27,15 +27,15 @@ export function defineAuthenticationRoutes(params: RouteDefinitionParams) { defineSessionRoutes(params); defineCommonRoutes(params); - if (params.authc.isProviderEnabled('basic') || params.authc.isProviderEnabled('token')) { + if (params.authc.isProviderTypeEnabled('basic') || params.authc.isProviderTypeEnabled('token')) { defineBasicRoutes(params); } - if (params.authc.isProviderEnabled('saml')) { + if (params.authc.isProviderTypeEnabled('saml')) { defineSAMLRoutes(params); } - if (params.authc.isProviderEnabled('oidc')) { + if (params.authc.isProviderTypeEnabled('oidc')) { defineOIDCRoutes(params); } } diff --git a/x-pack/plugins/security/server/routes/authentication/oidc.ts b/x-pack/plugins/security/server/routes/authentication/oidc.ts index 96c36af20e982..d325a453af9d1 100644 --- a/x-pack/plugins/security/server/routes/authentication/oidc.ts +++ b/x-pack/plugins/security/server/routes/authentication/oidc.ts @@ -7,11 +7,14 @@ import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; import { KibanaRequest, KibanaResponseFactory } from '../../../../../../src/core/server'; -import { OIDCAuthenticationFlow } from '../../authentication'; +import { OIDCLogin } from '../../authentication'; import { createCustomResourceResponse } from '.'; import { createLicensedRouteHandler } from '../licensed_route_handler'; import { wrapIntoCustomErrorResponse } from '../../errors'; -import { ProviderLoginAttempt } from '../../authentication/providers/oidc'; +import { + OIDCAuthenticationProvider, + ProviderLoginAttempt, +} from '../../authentication/providers/oidc'; import { RouteDefinitionParams } from '..'; /** @@ -118,7 +121,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route let loginAttempt: ProviderLoginAttempt | undefined; if (request.query.authenticationResponseURI) { loginAttempt = { - flow: OIDCAuthenticationFlow.Implicit, + type: OIDCLogin.LoginWithImplicitFlow, authenticationResponseURI: request.query.authenticationResponseURI, }; } else if (request.query.code || request.query.error) { @@ -133,7 +136,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route // failed) authentication from an OpenID Connect Provider during authorization code authentication flow. // See more details at https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth. loginAttempt = { - flow: OIDCAuthenticationFlow.AuthorizationCode, + type: OIDCLogin.LoginWithAuthorizationCodeFlow, // We pass the path only as we can't be sure of the full URL and Elasticsearch doesn't need it anyway. authenticationResponseURI: request.url.path!, }; @@ -145,7 +148,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route // An HTTP GET request with a query parameter named `iss` as part of a 3rd party initiated authentication. // See more details at https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin loginAttempt = { - flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, + type: OIDCLogin.LoginInitiatedBy3rdParty, iss: request.query.iss, loginHint: request.query.login_hint, }; @@ -181,7 +184,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route { unknowns: 'allow' } ), }, - options: { authRequired: false }, + options: { authRequired: false, xsrfRequired: false }, }, createLicensedRouteHandler(async (context, request, response) => { const serverBasePath = basePath.serverBasePath; @@ -193,7 +196,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route } return performOIDCLogin(request, response, { - flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, + type: OIDCLogin.LoginInitiatedBy3rdParty, iss: request.body.iss, loginHint: request.body.login_hint, }); @@ -224,7 +227,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route }, createLicensedRouteHandler(async (context, request, response) => { return performOIDCLogin(request, response, { - flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, + type: OIDCLogin.LoginInitiatedBy3rdParty, iss: request.query.iss, loginHint: request.query.login_hint, }); @@ -240,7 +243,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route // We handle the fact that the user might get redirected to Kibana while already having a session // Return an error notifying the user they are already logged in. const authenticationResult = await authc.login(request, { - provider: 'oidc', + provider: { type: OIDCAuthenticationProvider.type }, value: loginAttempt, }); diff --git a/x-pack/plugins/security/server/routes/authentication/saml.test.ts b/x-pack/plugins/security/server/routes/authentication/saml.test.ts index b4434715a72ba..af63dfa2f4471 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.test.ts @@ -5,7 +5,7 @@ */ import { Type } from '@kbn/config-schema'; -import { Authentication, AuthenticationResult, SAMLLoginStep } from '../../authentication'; +import { Authentication, AuthenticationResult, SAMLLogin } from '../../authentication'; import { defineSAMLRoutes } from './saml'; import { IRouter, RequestHandler, RouteConfig } from '../../../../../../src/core/server'; @@ -37,7 +37,7 @@ describe('SAML authentication routes', () => { }); it('correctly defines route.', () => { - expect(routeConfig.options).toEqual({ authRequired: false }); + expect(routeConfig.options).toEqual({ authRequired: false, xsrfRequired: false }); expect(routeConfig.validate).toEqual({ body: expect.any(Type), query: undefined, @@ -84,9 +84,9 @@ describe('SAML authentication routes', () => { ); expect(authc.login).toHaveBeenCalledWith(request, { - provider: 'saml', + provider: { type: 'saml' }, value: { - step: SAMLLoginStep.SAMLResponseReceived, + type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response', }, }); @@ -163,9 +163,9 @@ describe('SAML authentication routes', () => { ); expect(authc.login).toHaveBeenCalledWith(request, { - provider: 'saml', + provider: { type: 'saml' }, value: { - step: SAMLLoginStep.SAMLResponseReceived, + type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response', }, }); diff --git a/x-pack/plugins/security/server/routes/authentication/saml.ts b/x-pack/plugins/security/server/routes/authentication/saml.ts index 465ea61e12a4e..8f08f250a1c75 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.ts @@ -5,7 +5,8 @@ */ import { schema } from '@kbn/config-schema'; -import { SAMLLoginStep } from '../../authentication'; +import { SAMLLogin } from '../../authentication'; +import { SAMLAuthenticationProvider } from '../../authentication/providers'; import { createCustomResourceResponse } from '.'; import { RouteDefinitionParams } from '..'; @@ -15,7 +16,7 @@ import { RouteDefinitionParams } from '..'; export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: RouteDefinitionParams) { router.get( { - path: '/api/security/saml/capture-url-fragment', + path: '/internal/security/saml/capture-url-fragment', validate: false, options: { authRequired: false }, }, @@ -27,7 +28,7 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route Kibana SAML Login - + `, 'text/html', csp.header @@ -38,7 +39,7 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route router.get( { - path: '/api/security/saml/capture-url-fragment.js', + path: '/internal/security/saml/capture-url-fragment.js', validate: false, options: { authRequired: false }, }, @@ -47,7 +48,7 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route createCustomResourceResponse( ` window.location.replace( - '${basePath.serverBasePath}/api/security/saml/start?redirectURLFragment=' + encodeURIComponent(window.location.hash) + '${basePath.serverBasePath}/internal/security/saml/start?redirectURLFragment=' + encodeURIComponent(window.location.hash) ); `, 'text/javascript', @@ -59,7 +60,7 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route router.get( { - path: '/api/security/saml/start', + path: '/internal/security/saml/start', validate: { query: schema.object({ redirectURLFragment: schema.string() }), }, @@ -68,9 +69,9 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route async (context, request, response) => { try { const authenticationResult = await authc.login(request, { - provider: 'saml', + provider: { type: SAMLAuthenticationProvider.type }, value: { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: request.query.redirectURLFragment, }, }); @@ -97,17 +98,14 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route RelayState: schema.maybe(schema.string()), }), }, - options: { authRequired: false }, + options: { authRequired: false, xsrfRequired: false }, }, async (context, request, response) => { try { - // When authenticating using SAML we _expect_ to redirect to the SAML Identity provider. + // When authenticating using SAML we _expect_ to redirect to the Kibana target location. const authenticationResult = await authc.login(request, { - provider: 'saml', - value: { - step: SAMLLoginStep.SAMLResponseReceived, - samlResponse: request.body.SAMLResponse, - }, + provider: { type: SAMLAuthenticationProvider.type }, + value: { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: request.body.SAMLResponse }, }); if (authenticationResult.redirected()) { diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index 0821ed8b96af9..aaefdad6c221a 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -11,17 +11,19 @@ import { } from '../../../../../src/core/server/mocks'; import { authenticationMock } from '../authentication/index.mock'; import { authorizationMock } from '../authorization/index.mock'; -import { ConfigSchema } from '../config'; +import { ConfigSchema, createConfig } from '../config'; import { licenseMock } from '../../common/licensing/index.mock'; export const routeDefinitionParamsMock = { - create: () => ({ + create: (config: Record = {}) => ({ router: httpServiceMock.createRouter(), basePath: httpServiceMock.createBasePath(), csp: httpServiceMock.createSetupContract().csp, logger: loggingServiceMock.create().get(), clusterClient: elasticsearchServiceMock.createClusterClient(), - config: { ...ConfigSchema.validate({}), encryptionKey: 'some-enc-key' }, + config: createConfig(ConfigSchema.validate(config), loggingServiceMock.create().get(), { + isTLSEnabled: false, + }), authc: authenticationMock.create(), authz: authorizationMock.create(), license: licenseMock.create(), diff --git a/x-pack/plugins/security/server/routes/users/change_password.test.ts b/x-pack/plugins/security/server/routes/users/change_password.test.ts index c2db34dc3c33c..bac40202ee6ef 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.test.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.test.ts @@ -188,7 +188,7 @@ describe('Change password', () => { expect(authc.login).toHaveBeenCalledTimes(1); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'basic', + provider: { name: 'basic1' }, value: { username, password: 'new-password' }, }); }); @@ -196,7 +196,7 @@ describe('Change password', () => { it('successfully changes own password if provided old password is correct for non-basic provider.', async () => { const mockUser = mockAuthenticatedUser({ username: 'user', - authentication_provider: 'token', + authentication_provider: 'token1', }); authc.getCurrentUser.mockReturnValue(mockUser); authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockUser)); @@ -215,7 +215,7 @@ describe('Change password', () => { expect(authc.login).toHaveBeenCalledTimes(1); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'token', + provider: { name: 'token1' }, value: { username, password: 'new-password' }, }); }); diff --git a/x-pack/plugins/security/server/routes/users/change_password.ts b/x-pack/plugins/security/server/routes/users/change_password.ts index aa7e8bc26cc1f..e915cd8759ff1 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.ts @@ -81,7 +81,7 @@ export function defineChangeUserPasswordRoutes({ if (isUserChangingOwnPassword && currentSession) { try { const authenticationResult = await authc.login(request, { - provider: currentUser!.authentication_provider, + provider: { name: currentUser!.authentication_provider }, value: { username, password: newPassword }, }); diff --git a/x-pack/plugins/security/server/routes/views/index.test.ts b/x-pack/plugins/security/server/routes/views/index.test.ts index 63e8a518c6198..80f7f62a5ff43 100644 --- a/x-pack/plugins/security/server/routes/views/index.test.ts +++ b/x-pack/plugins/security/server/routes/views/index.test.ts @@ -11,7 +11,7 @@ import { routeDefinitionParamsMock } from '../index.mock'; describe('View routes', () => { it('does not register Login routes if both `basic` and `token` providers are disabled', () => { const routeParamsMock = routeDefinitionParamsMock.create(); - routeParamsMock.authc.isProviderEnabled.mockImplementation( + routeParamsMock.authc.isProviderTypeEnabled.mockImplementation( provider => provider !== 'basic' && provider !== 'token' ); @@ -29,7 +29,9 @@ describe('View routes', () => { it('registers Login routes if `basic` provider is enabled', () => { const routeParamsMock = routeDefinitionParamsMock.create(); - routeParamsMock.authc.isProviderEnabled.mockImplementation(provider => provider !== 'token'); + routeParamsMock.authc.isProviderTypeEnabled.mockImplementation( + provider => provider !== 'token' + ); defineViewRoutes(routeParamsMock); @@ -47,7 +49,29 @@ describe('View routes', () => { it('registers Login routes if `token` provider is enabled', () => { const routeParamsMock = routeDefinitionParamsMock.create(); - routeParamsMock.authc.isProviderEnabled.mockImplementation(provider => provider !== 'basic'); + routeParamsMock.authc.isProviderTypeEnabled.mockImplementation( + provider => provider !== 'basic' + ); + + defineViewRoutes(routeParamsMock); + + expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` + Array [ + "/login", + "/internal/security/login_state", + "/security/account", + "/security/logged_out", + "/logout", + "/security/overwritten_session", + ] + `); + }); + + it('registers Login routes if Login Selector is enabled even if both `token` and `basic` providers are not enabled', () => { + const routeParamsMock = routeDefinitionParamsMock.create({ + authc: { selector: { enabled: true } }, + }); + routeParamsMock.authc.isProviderTypeEnabled.mockReturnValue(false); defineViewRoutes(routeParamsMock); diff --git a/x-pack/plugins/security/server/routes/views/index.ts b/x-pack/plugins/security/server/routes/views/index.ts index 91e57aed44ab6..255989dfeb90c 100644 --- a/x-pack/plugins/security/server/routes/views/index.ts +++ b/x-pack/plugins/security/server/routes/views/index.ts @@ -12,7 +12,11 @@ import { defineOverwrittenSessionRoutes } from './overwritten_session'; import { RouteDefinitionParams } from '..'; export function defineViewRoutes(params: RouteDefinitionParams) { - if (params.authc.isProviderEnabled('basic') || params.authc.isProviderEnabled('token')) { + if ( + params.config.authc.selector.enabled || + params.authc.isProviderTypeEnabled('basic') || + params.authc.isProviderTypeEnabled('token') + ) { defineLoginRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/views/login.test.ts b/x-pack/plugins/security/server/routes/views/login.test.ts index d14aa226e17ba..9217d5a437f9c 100644 --- a/x-pack/plugins/security/server/routes/views/login.test.ts +++ b/x-pack/plugins/security/server/routes/views/login.test.ts @@ -13,21 +13,22 @@ import { IRouter, } from '../../../../../../src/core/server'; import { SecurityLicense } from '../../../common/licensing'; -import { Authentication } from '../../authentication'; +import { LoginState } from '../../../common/login_state'; +import { ConfigType } from '../../config'; import { defineLoginRoutes } from './login'; import { coreMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { routeDefinitionParamsMock } from '../index.mock'; describe('Login view routes', () => { - let authc: jest.Mocked; let router: jest.Mocked; let license: jest.Mocked; + let config: ConfigType; beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); - authc = routeParamsMock.authc; router = routeParamsMock.router; license = routeParamsMock.license; + config = routeParamsMock.config; defineLoginRoutes(routeParamsMock); }); @@ -45,7 +46,7 @@ describe('Login view routes', () => { }); it('correctly defines route.', () => { - expect(routeConfig.options).toEqual({ authRequired: false }); + expect(routeConfig.options).toEqual({ authRequired: 'optional' }); expect(routeConfig.validate).toEqual({ body: undefined, @@ -73,7 +74,7 @@ describe('Login view routes', () => { ); }); - it('redirects user to the root page if they have a session already or login is disabled.', async () => { + it('redirects user to the root page if they are authenticated or login is disabled.', async () => { for (const { query, expectedLocation } of [ { query: {}, expectedLocation: '/mock-server-basepath/' }, { @@ -85,27 +86,27 @@ describe('Login view routes', () => { expectedLocation: '/mock-server-basepath/', }, ]) { - const request = httpServerMock.createKibanaRequest({ query }); + // Redirect if user is authenticated even if `showLogin` is `true`. + let request = httpServerMock.createKibanaRequest({ + query, + auth: { isAuthenticated: true }, + }); (request as any).url = new URL( `${request.url.path}${request.url.search}`, 'https://kibana.co' ); - - // Redirect if user has an active session even if `showLogin` is `true`. - authc.getSessionInfo.mockResolvedValue({ - provider: 'basic', - now: 0, - idleTimeoutExpiration: null, - lifespanExpiration: null, - }); license.getFeatures.mockReturnValue({ showLogin: true } as any); await expect(routeHandler({} as any, request, kibanaResponseFactory)).resolves.toEqual({ options: { headers: { location: `${expectedLocation}` } }, status: 302, }); - // Redirect if `showLogin` is `false` even if user doesn't have an active session even. - authc.getSessionInfo.mockResolvedValue(null); + // Redirect if `showLogin` is `false` even if user is not authenticated. + request = httpServerMock.createKibanaRequest({ query, auth: { isAuthenticated: false } }); + (request as any).url = new URL( + `${request.url.path}${request.url.search}`, + 'https://kibana.co' + ); license.getFeatures.mockReturnValue({ showLogin: false } as any); await expect(routeHandler({} as any, request, kibanaResponseFactory)).resolves.toEqual({ options: { headers: { location: `${expectedLocation}` } }, @@ -114,11 +115,10 @@ describe('Login view routes', () => { } }); - it('renders view if user does not have an active session and login page can be shown.', async () => { - authc.getSessionInfo.mockResolvedValue(null); + it('renders view if user is not authenticated and login page can be shown.', async () => { license.getFeatures.mockReturnValue({ showLogin: true } as any); - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ auth: { isAuthenticated: false } }); const contextMock = coreMock.createRequestHandlerContext(); await expect( @@ -133,7 +133,6 @@ describe('Login view routes', () => { status: 200, }); - expect(authc.getSessionInfo).toHaveBeenCalledWith(request); expect(contextMock.rendering.render).toHaveBeenCalledWith({ includeUserSettings: false }); }); }); @@ -170,11 +169,18 @@ describe('Login view routes', () => { const request = httpServerMock.createKibanaRequest(); const contextMock = coreMock.createRequestHandlerContext(); + const expectedPayload = { + allowLogin: true, + layout: 'error-es-unavailable', + showLoginForm: true, + requiresSecureConnection: false, + selector: { enabled: false, providers: [] }, + }; await expect( routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) ).resolves.toEqual({ - options: { body: { allowLogin: true, layout: 'error-es-unavailable', showLogin: true } }, - payload: { allowLogin: true, layout: 'error-es-unavailable', showLogin: true }, + options: { body: expectedPayload }, + payload: expectedPayload, status: 200, }); }); @@ -185,13 +191,156 @@ describe('Login view routes', () => { const request = httpServerMock.createKibanaRequest(); const contextMock = coreMock.createRequestHandlerContext(); + const expectedPayload = { + allowLogin: true, + layout: 'form', + showLoginForm: true, + requiresSecureConnection: false, + selector: { enabled: false, providers: [] }, + }; + await expect( + routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) + ).resolves.toEqual({ + options: { body: expectedPayload }, + payload: expectedPayload, + status: 200, + }); + }); + + it('returns `requiresSecureConnection: true` if `secureCookies` is enabled in config.', async () => { + license.getFeatures.mockReturnValue({ allowLogin: true, showLogin: true } as any); + + const request = httpServerMock.createKibanaRequest(); + const contextMock = coreMock.createRequestHandlerContext(); + + config.secureCookies = true; + + const expectedPayload = expect.objectContaining({ requiresSecureConnection: true }); await expect( routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) ).resolves.toEqual({ - options: { body: { allowLogin: true, layout: 'form', showLogin: true } }, - payload: { allowLogin: true, layout: 'form', showLogin: true }, + options: { body: expectedPayload }, + payload: expectedPayload, status: 200, }); }); + + it('returns `showLoginForm: true` only if either `basic` or `token` provider is enabled.', async () => { + license.getFeatures.mockReturnValue({ allowLogin: true, showLogin: true } as any); + + const request = httpServerMock.createKibanaRequest(); + const contextMock = coreMock.createRequestHandlerContext(); + + const cases: Array<[boolean, ConfigType['authc']['sortedProviders']]> = [ + [false, []], + [true, [{ type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }]], + [true, [{ type: 'token', name: 'token1', options: { order: 0, showInSelector: true } }]], + ]; + + for (const [showLoginForm, sortedProviders] of cases) { + config.authc.sortedProviders = sortedProviders; + + const expectedPayload = expect.objectContaining({ showLoginForm }); + await expect( + routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) + ).resolves.toEqual({ + options: { body: expectedPayload }, + payload: expectedPayload, + status: 200, + }); + } + }); + + it('correctly returns `selector` information.', async () => { + license.getFeatures.mockReturnValue({ allowLogin: true, showLogin: true } as any); + + const request = httpServerMock.createKibanaRequest(); + const contextMock = coreMock.createRequestHandlerContext(); + + const cases: Array<[ + boolean, + ConfigType['authc']['sortedProviders'], + LoginState['selector']['providers'] + ]> = [ + // selector is disabled, providers shouldn't be returned. + [ + false, + [ + { type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }, + { type: 'saml', name: 'saml1', options: { order: 1, showInSelector: true } }, + ], + [], + ], + // selector is enabled, but only basic/token is available, providers shouldn't be returned. + [ + true, + [{ type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }], + [], + ], + // selector is enabled, non-basic/token providers should be returned + [ + true, + [ + { + type: 'basic', + name: 'basic1', + options: { order: 0, showInSelector: true, description: 'some-desc1' }, + }, + { + type: 'saml', + name: 'saml1', + options: { order: 1, showInSelector: true, description: 'some-desc2' }, + }, + { + type: 'saml', + name: 'saml2', + options: { order: 2, showInSelector: true, description: 'some-desc3' }, + }, + ], + [ + { type: 'saml', name: 'saml1', description: 'some-desc2' }, + { type: 'saml', name: 'saml2', description: 'some-desc3' }, + ], + ], + // selector is enabled, only non-basic/token providers that are enabled in selector should be returned. + [ + true, + [ + { + type: 'basic', + name: 'basic1', + options: { order: 0, showInSelector: true, description: 'some-desc1' }, + }, + { + type: 'saml', + name: 'saml1', + options: { order: 1, showInSelector: false, description: 'some-desc2' }, + }, + { + type: 'saml', + name: 'saml2', + options: { order: 2, showInSelector: true, description: 'some-desc3' }, + }, + ], + [{ type: 'saml', name: 'saml2', description: 'some-desc3' }], + ], + ]; + + for (const [selectorEnabled, sortedProviders, expectedProviders] of cases) { + config.authc.selector.enabled = selectorEnabled; + config.authc.sortedProviders = sortedProviders; + + const expectedPayload = expect.objectContaining({ + selector: { enabled: selectorEnabled, providers: expectedProviders }, + }); + await expect( + routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) + ).resolves.toEqual({ + options: { body: expectedPayload }, + payload: expectedPayload, + status: 200, + }); + } + }); }); }); diff --git a/x-pack/plugins/security/server/routes/views/login.ts b/x-pack/plugins/security/server/routes/views/login.ts index ee1fe01ab1b22..4cabd4337971c 100644 --- a/x-pack/plugins/security/server/routes/views/login.ts +++ b/x-pack/plugins/security/server/routes/views/login.ts @@ -6,15 +6,16 @@ import { schema } from '@kbn/config-schema'; import { parseNext } from '../../../common/parse_next'; +import { LoginState } from '../../../common/login_state'; import { RouteDefinitionParams } from '..'; /** * Defines routes required for the Login view. */ export function defineLoginRoutes({ + config, router, logger, - authc, csp, basePath, license, @@ -31,15 +32,12 @@ export function defineLoginRoutes({ { unknowns: 'allow' } ), }, - options: { authRequired: false }, + options: { authRequired: 'optional' }, }, async (context, request, response) => { // Default to true if license isn't available or it can't be resolved for some reason. const shouldShowLogin = license.isEnabled() ? license.getFeatures().showLogin : true; - - // Authentication flow isn't triggered automatically for this route, so we should explicitly - // check whether user has an active session already. - const isUserAlreadyLoggedIn = (await authc.getSessionInfo(request)) !== null; + const isUserAlreadyLoggedIn = request.auth.isAuthenticated; if (isUserAlreadyLoggedIn || !shouldShowLogin) { logger.debug('User is already authenticated, redirecting...'); return response.redirected({ @@ -57,8 +55,30 @@ export function defineLoginRoutes({ router.get( { path: '/internal/security/login_state', validate: false, options: { authRequired: false } }, async (context, request, response) => { - const { showLogin, allowLogin, layout = 'form' } = license.getFeatures(); - return response.ok({ body: { showLogin, allowLogin, layout } }); + const { allowLogin, layout = 'form' } = license.getFeatures(); + const { sortedProviders, selector } = config.authc; + + let showLoginForm = false; + const providers = []; + for (const { type, name, options } of sortedProviders) { + if (options.showInSelector) { + if (type === 'basic' || type === 'token') { + showLoginForm = true; + } else if (selector.enabled) { + providers.push({ type, name, description: options.description }); + } + } + } + + const loginState: LoginState = { + allowLogin, + layout, + requiresSecureConnection: config.secureCookies, + showLoginForm, + selector: { enabled: selector.enabled, providers }, + }; + + return response.ok({ body: loginState }); } ); } diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 06ee0b91c8a5d..242ee890d4847 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -26,6 +26,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/oidc_api_integration/config.ts'), require.resolve('../test/oidc_api_integration/implicit_flow.config.ts'), require.resolve('../test/pki_api_integration/config.ts'), + require.resolve('../test/login_selector_api_integration/config.ts'), require.resolve('../test/spaces_api_integration/spaces_only/config.ts'), require.resolve('../test/spaces_api_integration/security_and_spaces/config_trial.ts'), require.resolve('../test/spaces_api_integration/security_and_spaces/config_basic.ts'), diff --git a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts index b561c9ea47513..81999826adbb1 100644 --- a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts +++ b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts @@ -8,10 +8,14 @@ import expect from '@kbn/expect'; import request, { Cookie } from 'request'; import { delay } from 'bluebird'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { + getMutualAuthenticationResponseToken, + getSPNEGOToken, +} from '../../fixtures/kerberos_tools'; export default function({ getService }: FtrProviderContext) { - const spnegoToken = - 'YIIChwYGKwYBBQUCoIICezCCAnegDTALBgkqhkiG9xIBAgKiggJkBIICYGCCAlwGCSqGSIb3EgECAgEAboICSzCCAkegAwIBBaEDAgEOogcDBQAAAAAAo4IBW2GCAVcwggFToAMCAQWhERsPVEVTVC5FTEFTVElDLkNPohwwGqADAgEDoRMwERsESFRUUBsJbG9jYWxob3N0o4IBGTCCARWgAwIBEqEDAgECooIBBwSCAQNBN2a1Rso+KEJsDwICYLCt7ACLzdlbhEZF5YNsehO109b/WiZR1VTK6kCQyDdBdQFefyvV8EiC35mz7XnTb239nWz6xBGbdmtjSfF0XzpXKbL/zGzLEKkEXQuqFLPUN6qEJXsh0OoNdj9OWwmTr93FVyugs1hO/E5wjlAe2SDYpBN6uZICXu6dFg9nLQKkb/XgbgKM7ZZvgA/UElWDgHav4nPO1VWppCCLKHqXTRnvpr/AsxeON4qeJLaukxBigfIaJlLFMNQal5H7MyXa0j3Y1sckbURnWoBt6r4XE7c8F8cz0rYoGwoCO+Cs5tNutKY6XcsAFbLh59hjgIkhVBhhyTeypIHSMIHPoAMCARKigccEgcSsXqIRAcHfZivrbHfsnvbFgmzmnrKVPFNtJ9Hl23KunCsNW49nP4VF2dEf9n12prDaIguJDV5LPHpTew9rmCj1GCahKJ9bJbRKIgImLFd+nelm3E2zxRqAhrgM1469oDg0ksE3+5lJBuJlVEECMp0F/gxvEiL7DhasICqw+FOJ/jD9QUYvg+E6BIxWgZyPszaxerzBBszAhIF1rxCHRRL1KLjskNeJlBhH77DkAO6AEmsYGdsgEq7b7uCov9PKPiiPAuFF'; + const spnegoToken = getSPNEGOToken(); + const supertest = getService('supertestWithoutAuth'); const config = getService('config'); @@ -105,7 +109,7 @@ export default function({ getService }: FtrProviderContext) { // Verify that mutual authentication works. expect(response.headers['www-authenticate']).to.be( - 'Negotiate oRQwEqADCgEAoQsGCSqGSIb3EgECAg==' + `Negotiate ${getMutualAuthenticationResponseToken()}` ); const cookies = response.headers['set-cookie']; diff --git a/x-pack/test/kerberos_api_integration/fixtures/kerberos_tools.ts b/x-pack/test/kerberos_api_integration/fixtures/kerberos_tools.ts new file mode 100644 index 0000000000000..2fed5d475cd5c --- /dev/null +++ b/x-pack/test/kerberos_api_integration/fixtures/kerberos_tools.ts @@ -0,0 +1,13 @@ +/* + * 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 function getSPNEGOToken() { + return 'YIIChwYGKwYBBQUCoIICezCCAnegDTALBgkqhkiG9xIBAgKiggJkBIICYGCCAlwGCSqGSIb3EgECAgEAboICSzCCAkegAwIBBaEDAgEOogcDBQAAAAAAo4IBW2GCAVcwggFToAMCAQWhERsPVEVTVC5FTEFTVElDLkNPohwwGqADAgEDoRMwERsESFRUUBsJbG9jYWxob3N0o4IBGTCCARWgAwIBEqEDAgECooIBBwSCAQNBN2a1Rso+KEJsDwICYLCt7ACLzdlbhEZF5YNsehO109b/WiZR1VTK6kCQyDdBdQFefyvV8EiC35mz7XnTb239nWz6xBGbdmtjSfF0XzpXKbL/zGzLEKkEXQuqFLPUN6qEJXsh0OoNdj9OWwmTr93FVyugs1hO/E5wjlAe2SDYpBN6uZICXu6dFg9nLQKkb/XgbgKM7ZZvgA/UElWDgHav4nPO1VWppCCLKHqXTRnvpr/AsxeON4qeJLaukxBigfIaJlLFMNQal5H7MyXa0j3Y1sckbURnWoBt6r4XE7c8F8cz0rYoGwoCO+Cs5tNutKY6XcsAFbLh59hjgIkhVBhhyTeypIHSMIHPoAMCARKigccEgcSsXqIRAcHfZivrbHfsnvbFgmzmnrKVPFNtJ9Hl23KunCsNW49nP4VF2dEf9n12prDaIguJDV5LPHpTew9rmCj1GCahKJ9bJbRKIgImLFd+nelm3E2zxRqAhrgM1469oDg0ksE3+5lJBuJlVEECMp0F/gxvEiL7DhasICqw+FOJ/jD9QUYvg+E6BIxWgZyPszaxerzBBszAhIF1rxCHRRL1KLjskNeJlBhH77DkAO6AEmsYGdsgEq7b7uCov9PKPiiPAuFF'; +} + +export function getMutualAuthenticationResponseToken() { + return 'oRQwEqADCgEAoQsGCSqGSIb3EgECAg=='; +} diff --git a/x-pack/test/login_selector_api_integration/apis/index.ts b/x-pack/test/login_selector_api_integration/apis/index.ts new file mode 100644 index 0000000000000..35f83733a7105 --- /dev/null +++ b/x-pack/test/login_selector_api_integration/apis/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { FtrProviderContext } from '../ftr_provider_context'; + +export default function({ loadTestFile }: FtrProviderContext) { + describe('apis', function() { + this.tags('ciGroup6'); + loadTestFile(require.resolve('./login_selector')); + }); +} diff --git a/x-pack/test/login_selector_api_integration/apis/login_selector.ts b/x-pack/test/login_selector_api_integration/apis/login_selector.ts new file mode 100644 index 0000000000000..3be96d27186d9 --- /dev/null +++ b/x-pack/test/login_selector_api_integration/apis/login_selector.ts @@ -0,0 +1,545 @@ +/* + * 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 request, { Cookie } from 'request'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import url from 'url'; +import { CA_CERT_PATH } from '@kbn/dev-utils'; +import expect from '@kbn/expect'; +import { getStateAndNonce } from '../../oidc_api_integration/fixtures/oidc_tools'; +import { + getMutualAuthenticationResponseToken, + getSPNEGOToken, +} from '../../kerberos_api_integration/fixtures/kerberos_tools'; +import { getSAMLRequestId, getSAMLResponse } from '../../saml_api_integration/fixtures/saml_tools'; +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const randomness = getService('randomness'); + const supertest = getService('supertestWithoutAuth'); + const config = getService('config'); + + const kibanaServerConfig = config.get('servers.kibana'); + const validUsername = kibanaServerConfig.username; + const validPassword = kibanaServerConfig.password; + + const CA_CERT = readFileSync(CA_CERT_PATH); + const CLIENT_CERT = readFileSync( + resolve(__dirname, '../../pki_api_integration/fixtures/first_client.p12') + ); + + async function checkSessionCookie(sessionCookie: Cookie, username: string, providerName: string) { + expect(sessionCookie.key).to.be('sid'); + expect(sessionCookie.value).to.not.be.empty(); + expect(sessionCookie.path).to.be('/'); + expect(sessionCookie.httpOnly).to.be(true); + + const apiResponse = await supertest + .get('/internal/security/me') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(apiResponse.body).to.only.have.keys([ + 'username', + 'full_name', + 'email', + 'roles', + 'metadata', + 'enabled', + 'authentication_realm', + 'lookup_realm', + 'authentication_provider', + ]); + + expect(apiResponse.body.username).to.be(username); + expect(apiResponse.body.authentication_provider).to.be(providerName); + } + + describe('Login Selector', () => { + it('should redirect user to a login selector', async () => { + const response = await supertest + .get('/abc/xyz/handshake?one=two three') + .ca(CA_CERT) + .expect(302); + expect(response.headers['set-cookie']).to.be(undefined); + expect(response.headers.location).to.be( + '/login?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three' + ); + }); + + it('should allow access to login selector with intermediate authentication cookie', async () => { + const handshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ providerType: 'saml', providerName: 'saml1', currentURL: 'https://kibana.com/' }) + .expect(200); + + // The cookie that includes some state of the in-progress authentication, that doesn't allow + // to fully authenticate user yet. + const intermediateAuthCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + + await supertest + .get('/login') + .ca(CA_CERT) + .set('Cookie', intermediateAuthCookie.cookieString()) + .expect(200); + }); + + describe('SAML', () => { + function createSAMLResponse(options = {}) { + return getSAMLResponse({ + destination: `http://localhost:${kibanaServerConfig.port}/api/security/saml/callback`, + sessionIndex: String(randomness.naturalNumber()), + ...options, + }); + } + + it('should be able to log in via IdP initiated login for any configured realm', async () => { + for (const providerName of ['saml1', 'saml2']) { + const authenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .send({ + SAMLResponse: await createSAMLResponse({ + issuer: `http://www.elastic.co/${providerName}`, + }), + }) + .expect(302); + + // User should be redirected to the base URL. + expect(authenticationResponse.headers.location).to.be('/'); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'a@b.c', providerName); + } + }); + + it('should be able to log in via IdP initiated login even if session with other provider type exists', async () => { + const basicAuthenticationResponse = await supertest + .post('/internal/security/login') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ username: validUsername, password: validPassword }) + .expect(204); + + const basicSessionCookie = request.cookie( + basicAuthenticationResponse.headers['set-cookie'][0] + )!; + await checkSessionCookie(basicSessionCookie, 'elastic', 'basic1'); + + for (const providerName of ['saml1', 'saml2']) { + const authenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .set('Cookie', basicSessionCookie.cookieString()) + .send({ + SAMLResponse: await createSAMLResponse({ + issuer: `http://www.elastic.co/${providerName}`, + }), + }) + .expect(302); + + // It should be `/overwritten_session` instead of `/` once it's generalized. + expect(authenticationResponse.headers.location).to.be('/'); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'a@b.c', providerName); + } + }); + + it('should be able to log in via IdP initiated login even if session with other SAML provider exists', async () => { + // First login with `saml1`. + const saml1AuthenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .send({ + SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml1` }), + }) + .expect(302); + + const saml1SessionCookie = request.cookie( + saml1AuthenticationResponse.headers['set-cookie'][0] + )!; + await checkSessionCookie(saml1SessionCookie, 'a@b.c', 'saml1'); + + // And now try to login with `saml2`. + const saml2AuthenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .set('Cookie', saml1SessionCookie.cookieString()) + .send({ + SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml2` }), + }) + .expect(302); + + // It should be `/overwritten_session` instead of `/` once it's generalized. + expect(saml2AuthenticationResponse.headers.location).to.be('/'); + + const saml2SessionCookie = request.cookie( + saml2AuthenticationResponse.headers['set-cookie'][0] + )!; + await checkSessionCookie(saml2SessionCookie, 'a@b.c', 'saml2'); + }); + + // Ideally we should be able to abandon intermediate session and let user log in, but for the + // time being we cannot distinguish errors coming from Elasticsearch for the case when SAML + // response just doesn't correspond to request ID we have in intermediate cookie and the case + // when something else has happened. + it('should fail for IdP initiated login if intermediate session with other SAML provider exists', async () => { + // First start authentication flow with `saml1`. + const saml1HandshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + expect( + saml1HandshakeResponse.body.location.startsWith(`https://elastic.co/sso/saml`) + ).to.be(true); + + const saml1HandshakeCookie = request.cookie( + saml1HandshakeResponse.headers['set-cookie'][0] + )!; + + // And now try to login with `saml2`. + await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .set('Cookie', saml1HandshakeCookie.cookieString()) + .send({ + SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml2` }), + }) + .expect(401); + }); + + it('should be able to log in via SP initiated login with any configured realm', async () => { + for (const providerName of ['saml1', 'saml2']) { + const handshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName, + currentURL: + 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + expect(handshakeResponse.body.location.startsWith(`https://elastic.co/sso/saml`)).to.be( + true + ); + + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + const samlRequestId = await getSAMLRequestId(handshakeResponse.body.location); + + const authenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .set('Cookie', handshakeCookie.cookieString()) + .send({ + SAMLResponse: await createSAMLResponse({ + inResponseTo: samlRequestId, + issuer: `http://www.elastic.co/${providerName}`, + }), + }) + .expect(302); + + // User should be redirected to the URL that initiated handshake. + expect(authenticationResponse.headers.location).to.be( + '/abc/xyz/handshake?one=two three#/workpad' + ); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'a@b.c', providerName); + } + }); + + it('should be able to log in via SP initiated login even if intermediate session with other SAML provider exists', async () => { + // First start authentication flow with `saml1`. + const saml1HandshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/saml1', + }) + .expect(200); + + expect( + saml1HandshakeResponse.body.location.startsWith(`https://elastic.co/sso/saml`) + ).to.be(true); + + const saml1HandshakeCookie = request.cookie( + saml1HandshakeResponse.headers['set-cookie'][0] + )!; + + // And now try to login with `saml2`. + const saml2HandshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .set('Cookie', saml1HandshakeCookie.cookieString()) + .send({ + providerType: 'saml', + providerName: 'saml2', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/saml2', + }) + .expect(200); + + expect( + saml2HandshakeResponse.body.location.startsWith(`https://elastic.co/sso/saml`) + ).to.be(true); + + const saml2HandshakeCookie = request.cookie( + saml2HandshakeResponse.headers['set-cookie'][0] + )!; + + const saml2AuthenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .set('Cookie', saml2HandshakeCookie.cookieString()) + .send({ + SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml2` }), + }) + .expect(302); + + expect(saml2AuthenticationResponse.headers.location).to.be( + '/abc/xyz/handshake?one=two three#/saml2' + ); + + const saml2SessionCookie = request.cookie( + saml2AuthenticationResponse.headers['set-cookie'][0] + )!; + await checkSessionCookie(saml2SessionCookie, 'a@b.c', 'saml2'); + }); + }); + + describe('Kerberos', () => { + it('should be able to log in from Login Selector', async () => { + const spnegoResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'kerberos', + providerName: 'kerberos1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(401); + + expect(spnegoResponse.headers['set-cookie']).to.be(undefined); + expect(spnegoResponse.headers['www-authenticate']).to.be('Negotiate'); + + const authenticationResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .set('Authorization', `Negotiate ${getSPNEGOToken()}`) + .send({ + providerType: 'kerberos', + providerName: 'kerberos1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + // Verify that mutual authentication works. + expect(authenticationResponse.headers['www-authenticate']).to.be( + `Negotiate ${getMutualAuthenticationResponseToken()}` + ); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie( + request.cookie(cookies[0])!, + 'tester@TEST.ELASTIC.CO', + 'kerberos1' + ); + }); + + it('should be able to log in from Login Selector even if client provides certificate and PKI is enabled', async () => { + const spnegoResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'kerberos', + providerName: 'kerberos1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(401); + + expect(spnegoResponse.headers['set-cookie']).to.be(undefined); + expect(spnegoResponse.headers['www-authenticate']).to.be('Negotiate'); + + const authenticationResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .set('Authorization', `Negotiate ${getSPNEGOToken()}`) + .send({ + providerType: 'kerberos', + providerName: 'kerberos1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + // Verify that mutual authentication works. + expect(authenticationResponse.headers['www-authenticate']).to.be( + `Negotiate ${getMutualAuthenticationResponseToken()}` + ); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie( + request.cookie(cookies[0])!, + 'tester@TEST.ELASTIC.CO', + 'kerberos1' + ); + }); + }); + + describe('OpenID Connect', () => { + it('should be able to log in via IdP initiated login', async () => { + const handshakeResponse = await supertest + .get('/api/security/oidc/initiate_login?iss=https://test-op.elastic.co') + .ca(CA_CERT) + .expect(302); + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + + // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens + const { state, nonce } = getStateAndNonce(handshakeResponse.headers.location); + await supertest + .post('/api/oidc_provider/setup') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ nonce }) + .expect(200); + + const authenticationResponse = await supertest + .get(`/api/security/oidc/callback?code=code2&state=${state}`) + .ca(CA_CERT) + .set('Cookie', handshakeCookie.cookieString()) + .expect(302); + + // User should be redirected to the base URL. + expect(authenticationResponse.headers.location).to.be('/'); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'user2', 'oidc1'); + }); + + it('should be able to log in via SP initiated login', async () => { + const handshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three', + }) + .expect(200); + + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + const redirectURL = url.parse(handshakeResponse.body.location, true /* parseQueryString */); + expect( + handshakeResponse.body.location.startsWith( + `https://test-op.elastic.co/oauth2/v1/authorize` + ) + ).to.be(true); + + expect(redirectURL.query.scope).to.not.be.empty(); + expect(redirectURL.query.response_type).to.not.be.empty(); + expect(redirectURL.query.client_id).to.not.be.empty(); + expect(redirectURL.query.redirect_uri).to.not.be.empty(); + expect(redirectURL.query.state).to.not.be.empty(); + expect(redirectURL.query.nonce).to.not.be.empty(); + + // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens + const { state, nonce } = redirectURL.query; + await supertest + .post('/api/oidc_provider/setup') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ nonce }) + .expect(200); + + const authenticationResponse = await supertest + .get(`/api/security/oidc/callback?code=code1&state=${state}`) + .ca(CA_CERT) + .set('Cookie', handshakeCookie.cookieString()) + .expect(302); + + // User should be redirected to the URL that initiated handshake. + expect(authenticationResponse.headers.location).to.be('/abc/xyz/handshake?one=two three'); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'user1', 'oidc1'); + }); + }); + + describe('PKI', () => { + it('should redirect user to a login selector even if client provides certificate', async () => { + const response = await supertest + .get('/abc/xyz/handshake?one=two three') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .expect(302); + expect(response.headers['set-cookie']).to.be(undefined); + expect(response.headers.location).to.be( + '/login?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three' + ); + }); + + it('should be able to log in from Login Selector', async () => { + const authenticationResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'pki', + providerName: 'pki1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'first_client', 'pki1'); + }); + }); + }); +} diff --git a/x-pack/test/login_selector_api_integration/config.ts b/x-pack/test/login_selector_api_integration/config.ts new file mode 100644 index 0000000000000..6ca9d19b74c17 --- /dev/null +++ b/x-pack/test/login_selector_api_integration/config.ts @@ -0,0 +1,141 @@ +/* + * 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 { resolve } from 'path'; +import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function({ readConfigFile }: FtrConfigProviderContext) { + const kibanaAPITestsConfig = await readConfigFile( + require.resolve('../../../test/api_integration/config.js') + ); + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.js')); + const kibanaPort = xPackAPITestsConfig.get('servers.kibana.port'); + + const kerberosKeytabPath = resolve(__dirname, '../kerberos_api_integration/fixtures/krb5.keytab'); + const kerberosConfigPath = resolve(__dirname, '../kerberos_api_integration/fixtures/krb5.conf'); + + const oidcJWKSPath = resolve(__dirname, '../oidc_api_integration/fixtures/jwks.json'); + const oidcIdPPlugin = resolve(__dirname, '../oidc_api_integration/fixtures/oidc_provider'); + + const pkiKibanaCAPath = resolve(__dirname, '../pki_api_integration/fixtures/kibana_ca.crt'); + + const saml1IdPMetadataPath = resolve( + __dirname, + '../saml_api_integration/fixtures/idp_metadata.xml' + ); + const saml2IdPMetadataPath = resolve( + __dirname, + '../saml_api_integration/fixtures/idp_metadata_2.xml' + ); + + const servers = { + ...xPackAPITestsConfig.get('servers'), + elasticsearch: { + ...xPackAPITestsConfig.get('servers.elasticsearch'), + protocol: 'https', + }, + kibana: { + ...xPackAPITestsConfig.get('servers.kibana'), + protocol: 'https', + }, + }; + + return { + testFiles: [require.resolve('./apis')], + servers, + security: { disableTestUser: true }, + services: { + randomness: kibanaAPITestsConfig.get('services.randomness'), + legacyEs: kibanaAPITestsConfig.get('services.legacyEs'), + supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), + }, + junit: { + reportName: 'X-Pack Login Selector API Integration Tests', + }, + + esTestCluster: { + ...xPackAPITestsConfig.get('esTestCluster'), + ssl: true, + serverArgs: [ + ...xPackAPITestsConfig.get('esTestCluster.serverArgs'), + 'xpack.security.authc.token.enabled=true', + 'xpack.security.authc.token.timeout=15s', + 'xpack.security.http.ssl.client_authentication=optional', + 'xpack.security.http.ssl.verification_mode=certificate', + 'xpack.security.authc.realms.native.native1.order=0', + 'xpack.security.authc.realms.kerberos.kerb1.order=1', + `xpack.security.authc.realms.kerberos.kerb1.keytab.path=${kerberosKeytabPath}`, + 'xpack.security.authc.realms.pki.pki1.order=2', + 'xpack.security.authc.realms.pki.pki1.delegation.enabled=true', + `xpack.security.authc.realms.pki.pki1.certificate_authorities=${CA_CERT_PATH}`, + 'xpack.security.authc.realms.saml.saml1.order=3', + `xpack.security.authc.realms.saml.saml1.idp.metadata.path=${saml1IdPMetadataPath}`, + 'xpack.security.authc.realms.saml.saml1.idp.entity_id=http://www.elastic.co/saml1', + `xpack.security.authc.realms.saml.saml1.sp.entity_id=http://localhost:${kibanaPort}`, + `xpack.security.authc.realms.saml.saml1.sp.logout=http://localhost:${kibanaPort}/logout`, + `xpack.security.authc.realms.saml.saml1.sp.acs=http://localhost:${kibanaPort}/api/security/saml/callback`, + 'xpack.security.authc.realms.saml.saml1.attributes.principal=urn:oid:0.0.7', + 'xpack.security.authc.realms.oidc.oidc1.order=4', + `xpack.security.authc.realms.oidc.oidc1.rp.client_id=0oa8sqpov3TxMWJOt356`, + `xpack.security.authc.realms.oidc.oidc1.rp.client_secret=0oa8sqpov3TxMWJOt356`, + `xpack.security.authc.realms.oidc.oidc1.rp.response_type=code`, + `xpack.security.authc.realms.oidc.oidc1.rp.redirect_uri=https://localhost:${kibanaPort}/api/security/oidc/callback`, + `xpack.security.authc.realms.oidc.oidc1.op.authorization_endpoint=https://test-op.elastic.co/oauth2/v1/authorize`, + `xpack.security.authc.realms.oidc.oidc1.op.endsession_endpoint=https://test-op.elastic.co/oauth2/v1/endsession`, + `xpack.security.authc.realms.oidc.oidc1.op.token_endpoint=https://localhost:${kibanaPort}/api/oidc_provider/token_endpoint`, + `xpack.security.authc.realms.oidc.oidc1.op.userinfo_endpoint=https://localhost:${kibanaPort}/api/oidc_provider/userinfo_endpoint`, + `xpack.security.authc.realms.oidc.oidc1.op.issuer=https://test-op.elastic.co`, + `xpack.security.authc.realms.oidc.oidc1.op.jwkset_path=${oidcJWKSPath}`, + `xpack.security.authc.realms.oidc.oidc1.claims.principal=sub`, + `xpack.security.authc.realms.oidc.oidc1.ssl.certificate_authorities=${CA_CERT_PATH}`, + 'xpack.security.authc.realms.saml.saml2.order=5', + `xpack.security.authc.realms.saml.saml2.idp.metadata.path=${saml2IdPMetadataPath}`, + 'xpack.security.authc.realms.saml.saml2.idp.entity_id=http://www.elastic.co/saml2', + `xpack.security.authc.realms.saml.saml2.sp.entity_id=http://localhost:${kibanaPort}`, + `xpack.security.authc.realms.saml.saml2.sp.logout=http://localhost:${kibanaPort}/logout`, + `xpack.security.authc.realms.saml.saml2.sp.acs=http://localhost:${kibanaPort}/api/security/saml/callback`, + 'xpack.security.authc.realms.saml.saml2.attributes.principal=urn:oid:0.0.7', + ], + serverEnvVars: { + // We're going to use the same TGT multiple times and during a short period of time, so we + // have to disable replay cache so that ES doesn't complain about that. + ES_JAVA_OPTS: `-Djava.security.krb5.conf=${kerberosConfigPath} -Dsun.security.krb5.rcache=none`, + }, + }, + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${oidcIdPPlugin}`, + '--optimize.enabled=false', + '--server.ssl.enabled=true', + `--server.ssl.key=${KBN_KEY_PATH}`, + `--server.ssl.certificate=${KBN_CERT_PATH}`, + `--server.ssl.certificateAuthorities=${JSON.stringify([CA_CERT_PATH, pkiKibanaCAPath])}`, + `--server.ssl.clientAuthentication=optional`, + `--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, + `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, + `--xpack.security.authc.providers=${JSON.stringify({ + basic: { basic1: { order: 0 } }, + kerberos: { kerberos1: { order: 4 } }, + pki: { pki1: { order: 2 } }, + oidc: { oidc1: { order: 3, realm: 'oidc1' } }, + saml: { + saml1: { order: 1, realm: 'saml1' }, + saml2: { order: 5, realm: 'saml2', maxRedirectURLSize: '100b' }, + }, + })}`, + '--server.xsrf.whitelist', + JSON.stringify([ + '/api/oidc_provider/token_endpoint', + '/api/oidc_provider/userinfo_endpoint', + ]), + ], + }, + }; +} diff --git a/x-pack/plugins/security/public/authentication/login/login_state.ts b/x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts similarity index 56% rename from x-pack/plugins/security/public/authentication/login/login_state.ts rename to x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts index 6ca38296706fe..e3add3748f56d 100644 --- a/x-pack/plugins/security/public/authentication/login/login_state.ts +++ b/x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LoginLayout } from '../../../common/licensing'; +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; -export interface LoginState { - layout: LoginLayout; - allowLogin: boolean; -} +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/login_selector_api_integration/services.ts b/x-pack/test/login_selector_api_integration/services.ts new file mode 100644 index 0000000000000..8bb2dae90bf59 --- /dev/null +++ b/x-pack/test/login_selector_api_integration/services.ts @@ -0,0 +1,14 @@ +/* + * 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 { services as commonServices } from '../common/services'; +import { services as apiIntegrationServices } from '../api_integration/services'; + +export const services = { + ...commonServices, + randomness: apiIntegrationServices.randomness, + supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, +}; diff --git a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts index fe772a3b1d460..ac16335b7f466 100644 --- a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts +++ b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts @@ -9,7 +9,6 @@ import request, { Cookie } from 'request'; import { delay } from 'bluebird'; import { readFileSync } from 'fs'; import { resolve } from 'path'; -// @ts-ignore import { CA_CERT_PATH } from '@kbn/dev-utils'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/pki_api_integration/config.ts b/x-pack/test/pki_api_integration/config.ts index 21ae1b40efa16..8177e4aa1afba 100644 --- a/x-pack/test/pki_api_integration/config.ts +++ b/x-pack/test/pki_api_integration/config.ts @@ -6,7 +6,6 @@ import { resolve } from 'path'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; -// @ts-ignore import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; import { services } from './services'; diff --git a/x-pack/test/saml_api_integration/apis/security/saml_login.ts b/x-pack/test/saml_api_integration/apis/security/saml_login.ts index e49d95f2ec6c2..a4cb34c13c0e1 100644 --- a/x-pack/test/saml_api_integration/apis/security/saml_login.ts +++ b/x-pack/test/saml_api_integration/apis/security/saml_login.ts @@ -108,11 +108,15 @@ export default function({ getService }: FtrProviderContext) { expect(handshakeCookie.path).to.be('/'); expect(handshakeCookie.httpOnly).to.be(true); - expect(handshakeResponse.headers.location).to.be('/api/security/saml/capture-url-fragment'); + expect(handshakeResponse.headers.location).to.be( + '/internal/security/saml/capture-url-fragment' + ); }); it('should return an HTML page that will extract URL fragment', async () => { - const response = await supertest.get('/api/security/saml/capture-url-fragment').expect(200); + const response = await supertest + .get('/internal/security/saml/capture-url-fragment') + .expect(200); const kibanaBaseURL = url.format({ ...config.get('servers.kibana'), auth: false }); const dom = new JSDOM(response.text, { @@ -127,7 +131,7 @@ export default function({ getService }: FtrProviderContext) { Object.defineProperty(window, 'location', { value: { hash: '#/workpad', - href: `${kibanaBaseURL}/api/security/saml/capture-url-fragment#/workpad`, + href: `${kibanaBaseURL}/internal/security/saml/capture-url-fragment#/workpad`, replace(newLocation: string) { this.href = newLocation; resolve(); @@ -149,13 +153,13 @@ export default function({ getService }: FtrProviderContext) { // Check that script that forwards URL fragment worked correctly. expect(dom.window.location.href).to.be( - '/api/security/saml/start?redirectURLFragment=%23%2Fworkpad' + '/internal/security/saml/start?redirectURLFragment=%23%2Fworkpad' ); }); }); describe('initiating handshake', () => { - const initiateHandshakeURL = `/api/security/saml/start?redirectURLFragment=%23%2Fworkpad`; + const initiateHandshakeURL = `/internal/security/saml/start?redirectURLFragment=%23%2Fworkpad`; let captureURLCookie: Cookie; beforeEach(async () => { @@ -202,9 +206,8 @@ export default function({ getService }: FtrProviderContext) { it('AJAX requests should not initiate handshake', async () => { const ajaxResponse = await supertest - .get(initiateHandshakeURL) + .get('/abc/xyz/handshake?one=two three') .set('kbn-xsrf', 'xxx') - .set('Cookie', captureURLCookie.cookieString()) .expect(401); expect(ajaxResponse.headers['set-cookie']).to.be(undefined); @@ -222,7 +225,7 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=%23%2Fworkpad`) + .get(`/internal/security/saml/start?redirectURLFragment=%23%2Fworkpad`) .set('Cookie', captureURLCookie.cookieString()) .expect(302); @@ -360,7 +363,9 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}`) + .get( + `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` + ) .set('Cookie', captureURLCookie.cookieString()) .expect(302); @@ -515,7 +520,9 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}`) + .get( + `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` + ) .set('Cookie', captureURLCookie.cookieString()) .expect(302); @@ -603,7 +610,9 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}`) + .get( + `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` + ) .set('Cookie', captureURLCookie.cookieString()) .expect(302); @@ -647,7 +656,9 @@ export default function({ getService }: FtrProviderContext) { expect(handshakeCookie.path).to.be('/'); expect(handshakeCookie.httpOnly).to.be(true); - expect(handshakeResponse.headers.location).to.be('/api/security/saml/capture-url-fragment'); + expect(handshakeResponse.headers.location).to.be( + '/internal/security/saml/capture-url-fragment' + ); }); }); @@ -662,7 +673,9 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}`) + .get( + `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` + ) .set('Cookie', captureURLCookie.cookieString()) .expect(302); @@ -798,12 +811,12 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; expect(captureURLResponse.headers.location).to.be( - '/api/security/saml/capture-url-fragment' + '/internal/security/saml/capture-url-fragment' ); // 2. Initiate SAML handshake. const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=%23%2F${'workpad'.repeat(10)}`) + .get(`/internal/security/saml/start?redirectURLFragment=%23%2F${'workpad'.repeat(10)}`) .set('Cookie', captureURLCookie.cookieString()) .expect(302); diff --git a/x-pack/test/saml_api_integration/config.ts b/x-pack/test/saml_api_integration/config.ts index 502d34d4c9e5d..0580c28555d16 100644 --- a/x-pack/test/saml_api_integration/config.ts +++ b/x-pack/test/saml_api_integration/config.ts @@ -37,7 +37,7 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { 'xpack.security.authc.token.timeout=15s', 'xpack.security.authc.realms.saml.saml1.order=0', `xpack.security.authc.realms.saml.saml1.idp.metadata.path=${idpPath}`, - 'xpack.security.authc.realms.saml.saml1.idp.entity_id=http://www.elastic.co', + 'xpack.security.authc.realms.saml.saml1.idp.entity_id=http://www.elastic.co/saml1', `xpack.security.authc.realms.saml.saml1.sp.entity_id=http://localhost:${kibanaPort}`, `xpack.security.authc.realms.saml.saml1.sp.logout=http://localhost:${kibanaPort}/logout`, `xpack.security.authc.realms.saml.saml1.sp.acs=http://localhost:${kibanaPort}/api/security/saml/callback`, diff --git a/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml b/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml index a890fe812987b..57b9e824c9d53 100644 --- a/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml +++ b/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml @@ -1,6 +1,6 @@ + entityID="http://www.elastic.co/saml1"> diff --git a/x-pack/test/saml_api_integration/fixtures/idp_metadata_2.xml b/x-pack/test/saml_api_integration/fixtures/idp_metadata_2.xml new file mode 100644 index 0000000000000..ff67779d7732c --- /dev/null +++ b/x-pack/test/saml_api_integration/fixtures/idp_metadata_2.xml @@ -0,0 +1,41 @@ + + + + + + + + MIIDOTCCAiGgAwIBAgIVANNWkg9lzNiLqNkMFhFKHcXyaZmqMA0GCSqGSIb3DQEB +CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu +ZXJhdGVkIENBMCAXDTE5MTIyNzE3MDM0MloYDzIwNjkxMjE0MTcwMzQyWjARMQ8w +DQYDVQQDEwZraWJhbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCQ +wYYbQtbRBKJ4uNZc2+IgRU+7NNL21ZebQlEIMgK7jAqOMrsW2b5DATz41Fd+GQFU +FUYYjwo+PQj6sJHshOJo/gNb32HrydvMI7YPvevkszkuEGCfXxQ3Dw2RTACLgD0Q +OCkwHvn3TMf0loloV/ePGWaZDYZaXi3a5DdWi/HFFoJysgF0JV2f6XyKhJkGaEfJ +s9pWX269zH/XQvGNx4BEimJpYB8h4JnDYPFIiQdqj+sl2b+kS1hH9kL5gBAMXjFU +vcNnX+PmyTjyJrGo75k0ku+spBf1bMwuQt3uSmM+TQIXkvFDmS0DOVESrpA5EC1T +BUGRz6o/I88Xx4Mud771AgMBAAGjYzBhMB0GA1UdDgQWBBQLB1Eo23M3Ss8MsFaz +V+Twcb3PmDAfBgNVHSMEGDAWgBQa7SYOe8NGcF00EbwPHA91YCsHSTAUBgNVHREE +DTALgglsb2NhbGhvc3QwCQYDVR0TBAIwADANBgkqhkiG9w0BAQsFAAOCAQEAnEl/ +z5IElIjvkK4AgMPrNcRlvIGDt2orEik7b6Jsq6/RiJQ7cSsYTZf7xbqyxNsUOTxv ++frj47MEN448H2nRvUxH29YR3XygV5aEwADSAhwaQWn0QfWTCZbJTmSoNEDtDOzX +TGDlAoCD9s9Xz9S1JpxY4H+WWRZrBSDM6SC1c6CzuEeZRuScNAjYD5mh2v6fOlSy +b8xJWSg0AFlJPCa3ZsA2SKbNqI0uNfJTnkXRm88Z2NHcgtlADbOLKauWfCrpgsCk +cZgo6yAYkOM148h/8wGla1eX+iE1R72NUABGydu8MSQKvc0emWJkGsC1/KqPlf/O +eOUsdwn1yDKHRxDHyA== + + + + + + + + + + diff --git a/x-pack/test/saml_api_integration/fixtures/saml_tools.ts b/x-pack/test/saml_api_integration/fixtures/saml_tools.ts index bbe0df7ff3a2c..a924d0964c245 100644 --- a/x-pack/test/saml_api_integration/fixtures/saml_tools.ts +++ b/x-pack/test/saml_api_integration/fixtures/saml_tools.ts @@ -45,14 +45,21 @@ export async function getSAMLResponse({ inResponseTo, sessionIndex, username = 'a@b.c', -}: { destination?: string; inResponseTo?: string; sessionIndex?: string; username?: string } = {}) { + issuer = 'http://www.elastic.co/saml1', +}: { + destination?: string; + inResponseTo?: string; + sessionIndex?: string; + username?: string; + issuer?: string; +} = {}) { const issueInstant = new Date().toISOString(); const notOnOrAfter = new Date(Date.now() + 3600 * 1000).toISOString(); const samlAssertionTemplateXML = ` - http://www.elastic.co + ${issuer} a@b.c @@ -99,7 +106,7 @@ export async function getSAMLResponse({ ${inResponseTo ? `InResponseTo="${inResponseTo}"` : ''} Version="2.0" IssueInstant="${issueInstant}" Destination="${destination}"> - http://www.elastic.co + ${issuer} ${signature.getSignedXml()} @@ -111,9 +118,11 @@ export async function getSAMLResponse({ export async function getLogoutRequest({ destination, sessionIndex, + issuer = 'http://www.elastic.co/saml1', }: { destination: string; sessionIndex: string; + issuer?: string; }) { const issueInstant = new Date().toISOString(); const logoutRequestTemplateXML = ` @@ -121,7 +130,7 @@ export async function getLogoutRequest({ Destination="${destination}" Consent="urn:oasis:names:tc:SAML:2.0:consent:unspecified" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"> - http://www.elastic.co + ${issuer} a@b.c ${sessionIndex} From 13baa5156151af258249afc6468f15b53fffeee0 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Mon, 23 Mar 2020 23:08:44 +0100 Subject: [PATCH 046/179] [APM] Collect telemetry about data/API performance (#51612) * [APM] Collect telemetry about data/API performance Closes #50757. * Ignore apm scripts package.json * Config flag for enabling/disabling telemetry collection --- src/dev/run_check_lockfile_symlinks.js | 2 + x-pack/legacy/plugins/apm/index.ts | 19 +- x-pack/legacy/plugins/apm/mappings.json | 773 +++++++++++++++++- x-pack/legacy/plugins/apm/scripts/.gitignore | 1 + .../legacy/plugins/apm/scripts/package.json | 10 + .../apm/scripts/setup-kibana-security.js | 1 + .../apm/scripts/upload-telemetry-data.js | 21 + .../download-telemetry-template.ts | 26 + .../generate-sample-documents.ts | 124 +++ .../scripts/upload-telemetry-data/index.ts | 208 +++++ .../elasticsearch_fieldnames.test.ts.snap | 48 +- x-pack/plugins/apm/common/agent_name.ts | 44 +- .../apm/common/apm_saved_object_constants.ts | 10 +- .../common/elasticsearch_fieldnames.test.ts | 15 +- .../apm/common/elasticsearch_fieldnames.ts | 11 +- x-pack/plugins/apm/kibana.json | 7 +- x-pack/plugins/apm/server/index.ts | 6 +- .../lib/apm_telemetry/__test__/index.test.ts | 83 -- .../collect_data_telemetry/index.ts | 77 ++ .../collect_data_telemetry/tasks.ts | 725 ++++++++++++++++ .../apm/server/lib/apm_telemetry/index.ts | 155 +++- .../apm/server/lib/apm_telemetry/types.ts | 118 +++ .../server/lib/helpers/setup_request.test.ts | 13 + x-pack/plugins/apm/server/plugin.ts | 37 +- .../server/routes/create_api/index.test.ts | 1 + x-pack/plugins/apm/server/routes/services.ts | 16 - .../apm/typings/elasticsearch/aggregations.ts | 26 +- .../apm/typings/es_schemas/raw/error_raw.ts | 2 + .../typings/es_schemas/raw/fields/observer.ts | 10 + .../apm/typings/es_schemas/raw/span_raw.ts | 2 + .../typings/es_schemas/raw/transaction_raw.ts | 2 + 31 files changed, 2387 insertions(+), 206 deletions(-) create mode 100644 x-pack/legacy/plugins/apm/scripts/.gitignore create mode 100644 x-pack/legacy/plugins/apm/scripts/package.json create mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js create mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts create mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts create mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts delete mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts create mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts create mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts create mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/types.ts create mode 100644 x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts diff --git a/src/dev/run_check_lockfile_symlinks.js b/src/dev/run_check_lockfile_symlinks.js index 54a8cdf638a78..6c6fc54638ee8 100644 --- a/src/dev/run_check_lockfile_symlinks.js +++ b/src/dev/run_check_lockfile_symlinks.js @@ -36,6 +36,8 @@ const IGNORE_FILE_GLOBS = [ '**/*fixtures*/**/*', // cypress isn't used in production, ignore it 'x-pack/legacy/plugins/apm/e2e/*', + // apm scripts aren't used in production, ignore them + 'x-pack/legacy/plugins/apm/scripts/*', ]; run(async ({ log }) => { diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index 0107997f233fe..594e8a4a7af72 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -14,7 +14,13 @@ import mappings from './mappings.json'; export const apm: LegacyPluginInitializer = kibana => { return new kibana.Plugin({ - require: ['kibana', 'elasticsearch', 'xpack_main', 'apm_oss'], + require: [ + 'kibana', + 'elasticsearch', + 'xpack_main', + 'apm_oss', + 'task_manager' + ], id: 'apm', configPrefix: 'xpack.apm', publicDir: resolve(__dirname, 'public'), @@ -71,7 +77,10 @@ export const apm: LegacyPluginInitializer = kibana => { autocreateApmIndexPattern: Joi.boolean().default(true), // service map - serviceMapEnabled: Joi.boolean().default(true) + serviceMapEnabled: Joi.boolean().default(true), + + // telemetry + telemetryCollectionEnabled: Joi.boolean().default(true) }).default(); }, @@ -107,10 +116,12 @@ export const apm: LegacyPluginInitializer = kibana => { } } }); - const apmPlugin = server.newPlatform.setup.plugins .apm as APMPluginContract; - apmPlugin.registerLegacyAPI({ server }); + + apmPlugin.registerLegacyAPI({ + server + }); } }); }; diff --git a/x-pack/legacy/plugins/apm/mappings.json b/x-pack/legacy/plugins/apm/mappings.json index 61bc90da28756..ba4c7a89ceaa8 100644 --- a/x-pack/legacy/plugins/apm/mappings.json +++ b/x-pack/legacy/plugins/apm/mappings.json @@ -1,20 +1,659 @@ { - "apm-services-telemetry": { + "apm-telemetry": { "properties": { + "agents": { + "properties": { + "dotnet": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "go": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "java": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "type": "object" + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "js-base": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "type": "object" + }, + "language": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "type": "object" + } + } + } + } + }, + "nodejs": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "python": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "ruby": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "rum-js": { + "properties": { + "agent": { + "type": "object" + }, + "service": { + "properties": { + "framework": { + "type": "object" + }, + "language": { + "type": "object" + }, + "runtime": { + "type": "object" + } + } + } + } + } + } + }, + "counts": { + "properties": { + "agent_configuration": { + "properties": { + "all": { + "type": "long" + } + } + }, + "error": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "max_error_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "max_transaction_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "services": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "sourcemap": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "span": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "traces": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + } + } + }, + "cardinality": { + "properties": { + "user_agent": { + "properties": { + "original": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + }, + "transaction": { + "properties": { + "name": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + } + } + }, "has_any_services": { "type": "boolean" }, + "indices": { + "properties": { + "all": { + "properties": { + "total": { + "properties": { + "docs": { + "properties": { + "count": { + "type": "long" + } + } + }, + "store": { + "properties": { + "size_in_bytes": { + "type": "long" + } + } + } + } + } + } + }, + "shards": { + "properties": { + "total": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "ml": { + "properties": { + "all_jobs_count": { + "type": "long" + } + } + } + } + }, + "retainment": { + "properties": { + "error": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "span": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, "services_per_agent": { "properties": { - "python": { + "dotnet": { "type": "long", "null_value": 0 }, - "java": { + "go": { "type": "long", "null_value": 0 }, - "nodejs": { + "java": { "type": "long", "null_value": 0 }, @@ -22,11 +661,11 @@ "type": "long", "null_value": 0 }, - "rum-js": { + "nodejs": { "type": "long", "null_value": 0 }, - "dotnet": { + "python": { "type": "long", "null_value": 0 }, @@ -34,11 +673,131 @@ "type": "long", "null_value": 0 }, - "go": { + "rum-js": { "type": "long", "null_value": 0 } } + }, + "tasks": { + "properties": { + "agent_configuration": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "agents": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "cardinality": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "groupings": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "indices_stats": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "processor_events": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "services": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "versions": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + } + } + }, + "version": { + "properties": { + "apm_server": { + "properties": { + "major": { + "type": "long" + }, + "minor": { + "type": "long" + }, + "patch": { + "type": "long" + } + } + } + } } } }, diff --git a/x-pack/legacy/plugins/apm/scripts/.gitignore b/x-pack/legacy/plugins/apm/scripts/.gitignore new file mode 100644 index 0000000000000..8ee01d321b721 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/.gitignore @@ -0,0 +1 @@ +yarn.lock diff --git a/x-pack/legacy/plugins/apm/scripts/package.json b/x-pack/legacy/plugins/apm/scripts/package.json new file mode 100644 index 0000000000000..9121449c53619 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/package.json @@ -0,0 +1,10 @@ +{ + "name": "apm-scripts", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "@octokit/rest": "^16.35.0", + "console-stamp": "^0.2.9" + } +} diff --git a/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js b/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js index 825c1a526fcc5..61ba2fdc7f7e3 100644 --- a/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js +++ b/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js @@ -16,6 +16,7 @@ ******************************/ // compile typescript on the fly +// eslint-disable-next-line import/no-extraneous-dependencies require('@babel/register')({ extensions: ['.ts'], plugins: ['@babel/plugin-proposal-optional-chaining'], diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js new file mode 100644 index 0000000000000..a99651c62dd7a --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js @@ -0,0 +1,21 @@ +/* + * 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. + */ + +// compile typescript on the fly +// eslint-disable-next-line import/no-extraneous-dependencies +require('@babel/register')({ + extensions: ['.ts'], + plugins: [ + '@babel/plugin-proposal-optional-chaining', + '@babel/plugin-proposal-nullish-coalescing-operator' + ], + presets: [ + '@babel/typescript', + ['@babel/preset-env', { targets: { node: 'current' } }] + ] +}); + +require('./upload-telemetry-data/index.ts'); diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts new file mode 100644 index 0000000000000..dfed9223ef708 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ + +// @ts-ignore +import { Octokit } from '@octokit/rest'; + +export async function downloadTelemetryTemplate(octokit: Octokit) { + const file = await octokit.repos.getContents({ + owner: 'elastic', + repo: 'telemetry', + path: 'config/templates/xpack-phone-home.json', + // @ts-ignore + mediaType: { + format: 'application/vnd.github.VERSION.raw' + } + }); + + if (Array.isArray(file.data)) { + throw new Error('Expected single response, got array'); + } + + return JSON.parse(Buffer.from(file.data.content!, 'base64').toString()); +} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts new file mode 100644 index 0000000000000..8d76063a7fdf6 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts @@ -0,0 +1,124 @@ +/* + * 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 { DeepPartial } from 'utility-types'; +import { + merge, + omit, + defaultsDeep, + range, + mapValues, + isPlainObject, + flatten +} from 'lodash'; +import uuid from 'uuid'; +import { + CollectTelemetryParams, + collectDataTelemetry + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; + +interface GenerateOptions { + days: number; + instances: number; + variation: { + min: number; + max: number; + }; +} + +const randomize = ( + value: unknown, + instanceVariation: number, + dailyGrowth: number +) => { + if (typeof value === 'boolean') { + return Math.random() > 0.5; + } + if (typeof value === 'number') { + return Math.round(instanceVariation * dailyGrowth * value); + } + return value; +}; + +const mapValuesDeep = ( + obj: Record, + iterator: (value: unknown, key: string, obj: Record) => unknown +): Record => + mapValues(obj, (val, key) => + isPlainObject(val) ? mapValuesDeep(val, iterator) : iterator(val, key!, obj) + ); + +export async function generateSampleDocuments( + options: DeepPartial & { + collectTelemetryParams: CollectTelemetryParams; + } +) { + const { collectTelemetryParams, ...preferredOptions } = options; + + const opts: GenerateOptions = defaultsDeep( + { + days: 100, + instances: 50, + variation: { + min: 0.1, + max: 4 + } + }, + preferredOptions + ); + + const sample = await collectDataTelemetry(collectTelemetryParams); + + console.log('Collected telemetry'); // eslint-disable-line no-console + console.log('\n' + JSON.stringify(sample, null, 2)); // eslint-disable-line no-console + + const dateOfScriptExecution = new Date(); + + return flatten( + range(0, opts.instances).map(instanceNo => { + const instanceId = uuid.v4(); + const defaults = { + cluster_uuid: instanceId, + stack_stats: { + kibana: { + versions: { + version: '8.0.0' + } + } + } + }; + + const instanceVariation = + Math.random() * (opts.variation.max - opts.variation.min) + + opts.variation.min; + + return range(0, opts.days).map(dayNo => { + const dailyGrowth = Math.pow(1.005, opts.days - 1 - dayNo); + + const timestamp = Date.UTC( + dateOfScriptExecution.getFullYear(), + dateOfScriptExecution.getMonth(), + -dayNo + ); + + const generated = mapValuesDeep(omit(sample, 'versions'), value => + randomize(value, instanceVariation, dailyGrowth) + ); + + return merge({}, defaults, { + timestamp, + stack_stats: { + kibana: { + plugins: { + apm: merge({}, sample, generated) + } + } + } + }); + }); + }) + ); +} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts new file mode 100644 index 0000000000000..bdc57eac412fc --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts @@ -0,0 +1,208 @@ +/* + * 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. + */ + +// This script downloads the telemetry mapping, runs the APM telemetry tasks, +// generates a bunch of randomized data based on the downloaded sample, +// and uploads it to a cluster of your choosing in the same format as it is +// stored in the telemetry cluster. Its purpose is twofold: +// - Easier testing of the telemetry tasks +// - Validate whether we can run the queries we want to on the telemetry data + +import fs from 'fs'; +import path from 'path'; +// @ts-ignore +import { Octokit } from '@octokit/rest'; +import { merge, chunk, flatten, pick, identity } from 'lodash'; +import axios from 'axios'; +import yaml from 'js-yaml'; +import { Client } from 'elasticsearch'; +import { argv } from 'yargs'; +import { promisify } from 'util'; +import { Logger } from 'kibana/server'; +// @ts-ignore +import consoleStamp from 'console-stamp'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { CollectTelemetryParams } from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; +import { downloadTelemetryTemplate } from './download-telemetry-template'; +import mapping from '../../mappings.json'; +import { generateSampleDocuments } from './generate-sample-documents'; + +consoleStamp(console, '[HH:MM:ss.l]'); + +const githubToken = process.env.GITHUB_TOKEN; + +if (!githubToken) { + throw new Error('GITHUB_TOKEN was not provided.'); +} + +const kibanaConfigDir = path.join(__filename, '../../../../../../../config'); +const kibanaDevConfig = path.join(kibanaConfigDir, 'kibana.dev.yml'); +const kibanaConfig = path.join(kibanaConfigDir, 'kibana.yml'); + +const xpackTelemetryIndexName = 'xpack-phone-home'; + +const loadedKibanaConfig = (yaml.safeLoad( + fs.readFileSync( + fs.existsSync(kibanaDevConfig) ? kibanaDevConfig : kibanaConfig, + 'utf8' + ) +) || {}) as {}; + +const cliEsCredentials = pick( + { + 'elasticsearch.username': process.env.ELASTICSEARCH_USERNAME, + 'elasticsearch.password': process.env.ELASTICSEARCH_PASSWORD, + 'elasticsearch.hosts': process.env.ELASTICSEARCH_HOST + }, + identity +) as { + 'elasticsearch.username': string; + 'elasticsearch.password': string; + 'elasticsearch.hosts': string; +}; + +const config = { + 'apm_oss.transactionIndices': 'apm-*', + 'apm_oss.metricsIndices': 'apm-*', + 'apm_oss.errorIndices': 'apm-*', + 'apm_oss.spanIndices': 'apm-*', + 'apm_oss.onboardingIndices': 'apm-*', + 'apm_oss.sourcemapIndices': 'apm-*', + 'elasticsearch.hosts': 'http://localhost:9200', + ...loadedKibanaConfig, + ...cliEsCredentials +}; + +async function uploadData() { + const octokit = new Octokit({ + auth: githubToken + }); + + const telemetryTemplate = await downloadTelemetryTemplate(octokit); + + const kibanaMapping = mapping['apm-telemetry']; + + const httpAuth = + config['elasticsearch.username'] && config['elasticsearch.password'] + ? { + username: config['elasticsearch.username'], + password: config['elasticsearch.password'] + } + : null; + + const client = new Client({ + host: config['elasticsearch.hosts'], + ...(httpAuth + ? { + httpAuth: `${httpAuth.username}:${httpAuth.password}` + } + : {}) + }); + + if (argv.clear) { + try { + await promisify(client.indices.delete.bind(client))({ + index: xpackTelemetryIndexName + }); + } catch (err) { + // 404 = index not found, totally okay + if (err.status !== 404) { + throw err; + } + } + } + + const axiosInstance = axios.create({ + baseURL: config['elasticsearch.hosts'], + ...(httpAuth ? { auth: httpAuth } : {}) + }); + + const newTemplate = merge(telemetryTemplate, { + settings: { + index: { mapping: { total_fields: { limit: 10000 } } } + } + }); + + // override apm mapping instead of merging + newTemplate.mappings.properties.stack_stats.properties.kibana.properties.plugins.properties.apm = kibanaMapping; + + await axiosInstance.put(`/_template/xpack-phone-home`, newTemplate); + + const sampleDocuments = await generateSampleDocuments({ + collectTelemetryParams: { + logger: (console as unknown) as Logger, + indices: { + ...config, + apmCustomLinkIndex: '.apm-custom-links', + apmAgentConfigurationIndex: '.apm-agent-configuration' + }, + search: body => { + return promisify(client.search.bind(client))({ + ...body, + requestTimeout: 120000 + }) as any; + }, + indicesStats: body => { + return promisify(client.indices.stats.bind(client))({ + ...body, + requestTimeout: 120000 + }) as any; + }, + transportRequest: (params => { + return axiosInstance[params.method](params.path); + }) as CollectTelemetryParams['transportRequest'] + } + }); + + const chunks = chunk(sampleDocuments, 250); + + await chunks.reduce>((prev, documents) => { + return prev.then(async () => { + const body = flatten( + documents.map(doc => [{ index: { _index: 'xpack-phone-home' } }, doc]) + ); + + return promisify(client.bulk.bind(client))({ + body, + refresh: true + }).then((response: any) => { + if (response.errors) { + const firstError = response.items.filter( + (item: any) => item.index.status >= 400 + )[0].index.error; + throw new Error(`Failed to upload documents: ${firstError.reason} `); + } + }); + }); + }, Promise.resolve()); +} + +uploadData() + .catch(e => { + if ('response' in e) { + if (typeof e.response === 'string') { + // eslint-disable-next-line no-console + console.log(e.response); + } else { + // eslint-disable-next-line no-console + console.log( + JSON.stringify( + e.response, + ['status', 'statusText', 'headers', 'data'], + 2 + ) + ); + } + } else { + // eslint-disable-next-line no-console + console.log(e); + } + process.exit(1); + }) + .then(() => { + // eslint-disable-next-line no-console + console.log('Finished uploading generated telemetry data'); + }); diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 897d4e979fce3..5de82a9ee8788 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -2,6 +2,8 @@ exports[`Error AGENT_NAME 1`] = `"java"`; +exports[`Error AGENT_VERSION 1`] = `"agent version"`; + exports[`Error CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Error CONTAINER_ID 1`] = `undefined`; @@ -56,7 +58,7 @@ exports[`Error METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Error OBSERVER_LISTENING 1`] = `undefined`; -exports[`Error OBSERVER_VERSION_MAJOR 1`] = `undefined`; +exports[`Error OBSERVER_VERSION_MAJOR 1`] = `8`; exports[`Error PARENT_ID 1`] = `"parentId"`; @@ -68,10 +70,20 @@ exports[`Error SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Error SERVICE_FRAMEWORK_NAME 1`] = `undefined`; +exports[`Error SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; + +exports[`Error SERVICE_LANGUAGE_NAME 1`] = `"nodejs"`; + +exports[`Error SERVICE_LANGUAGE_VERSION 1`] = `"v1337"`; + exports[`Error SERVICE_NAME 1`] = `"service name"`; exports[`Error SERVICE_NODE_NAME 1`] = `undefined`; +exports[`Error SERVICE_RUNTIME_NAME 1`] = `undefined`; + +exports[`Error SERVICE_RUNTIME_VERSION 1`] = `undefined`; + exports[`Error SERVICE_VERSION 1`] = `undefined`; exports[`Error SPAN_ACTION 1`] = `undefined`; @@ -112,10 +124,14 @@ exports[`Error URL_FULL 1`] = `undefined`; exports[`Error USER_AGENT_NAME 1`] = `undefined`; +exports[`Error USER_AGENT_ORIGINAL 1`] = `undefined`; + exports[`Error USER_ID 1`] = `undefined`; exports[`Span AGENT_NAME 1`] = `"java"`; +exports[`Span AGENT_VERSION 1`] = `"agent version"`; + exports[`Span CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Span CONTAINER_ID 1`] = `undefined`; @@ -170,7 +186,7 @@ exports[`Span METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Span OBSERVER_LISTENING 1`] = `undefined`; -exports[`Span OBSERVER_VERSION_MAJOR 1`] = `undefined`; +exports[`Span OBSERVER_VERSION_MAJOR 1`] = `8`; exports[`Span PARENT_ID 1`] = `"parentId"`; @@ -182,10 +198,20 @@ exports[`Span SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Span SERVICE_FRAMEWORK_NAME 1`] = `undefined`; +exports[`Span SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; + +exports[`Span SERVICE_LANGUAGE_NAME 1`] = `undefined`; + +exports[`Span SERVICE_LANGUAGE_VERSION 1`] = `undefined`; + exports[`Span SERVICE_NAME 1`] = `"service name"`; exports[`Span SERVICE_NODE_NAME 1`] = `undefined`; +exports[`Span SERVICE_RUNTIME_NAME 1`] = `undefined`; + +exports[`Span SERVICE_RUNTIME_VERSION 1`] = `undefined`; + exports[`Span SERVICE_VERSION 1`] = `undefined`; exports[`Span SPAN_ACTION 1`] = `"my action"`; @@ -226,10 +252,14 @@ exports[`Span URL_FULL 1`] = `undefined`; exports[`Span USER_AGENT_NAME 1`] = `undefined`; +exports[`Span USER_AGENT_ORIGINAL 1`] = `undefined`; + exports[`Span USER_ID 1`] = `undefined`; exports[`Transaction AGENT_NAME 1`] = `"java"`; +exports[`Transaction AGENT_VERSION 1`] = `"agent version"`; + exports[`Transaction CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Transaction CONTAINER_ID 1`] = `"container1234567890abcdef"`; @@ -284,7 +314,7 @@ exports[`Transaction METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Transaction OBSERVER_LISTENING 1`] = `undefined`; -exports[`Transaction OBSERVER_VERSION_MAJOR 1`] = `undefined`; +exports[`Transaction OBSERVER_VERSION_MAJOR 1`] = `8`; exports[`Transaction PARENT_ID 1`] = `"parentId"`; @@ -296,10 +326,20 @@ exports[`Transaction SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Transaction SERVICE_FRAMEWORK_NAME 1`] = `undefined`; +exports[`Transaction SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; + +exports[`Transaction SERVICE_LANGUAGE_NAME 1`] = `"nodejs"`; + +exports[`Transaction SERVICE_LANGUAGE_VERSION 1`] = `"v1337"`; + exports[`Transaction SERVICE_NAME 1`] = `"service name"`; exports[`Transaction SERVICE_NODE_NAME 1`] = `undefined`; +exports[`Transaction SERVICE_RUNTIME_NAME 1`] = `undefined`; + +exports[`Transaction SERVICE_RUNTIME_VERSION 1`] = `undefined`; + exports[`Transaction SERVICE_VERSION 1`] = `undefined`; exports[`Transaction SPAN_ACTION 1`] = `undefined`; @@ -340,4 +380,6 @@ exports[`Transaction URL_FULL 1`] = `"http://www.elastic.co"`; exports[`Transaction USER_AGENT_NAME 1`] = `"Other"`; +exports[`Transaction USER_AGENT_ORIGINAL 1`] = `"test original"`; + exports[`Transaction USER_ID 1`] = `"1337"`; diff --git a/x-pack/plugins/apm/common/agent_name.ts b/x-pack/plugins/apm/common/agent_name.ts index bb68eb88b8e18..085828b729ea5 100644 --- a/x-pack/plugins/apm/common/agent_name.ts +++ b/x-pack/plugins/apm/common/agent_name.ts @@ -4,36 +4,40 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AgentName } from '../typings/es_schemas/ui/fields/agent'; + /* * Agent names can be any string. This list only defines the official agents * that we might want to target specifically eg. linking to their documentation * & telemetry reporting. Support additional agent types by appending * definitions in mappings.json (for telemetry), the AgentName type, and the - * agentNames object. + * AGENT_NAMES array. */ -import { AgentName } from '../typings/es_schemas/ui/fields/agent'; -const agentNames: { [agentName in AgentName]: agentName } = { - python: 'python', - java: 'java', - nodejs: 'nodejs', - 'js-base': 'js-base', - 'rum-js': 'rum-js', - dotnet: 'dotnet', - ruby: 'ruby', - go: 'go' -}; +export const AGENT_NAMES: AgentName[] = [ + 'java', + 'js-base', + 'rum-js', + 'dotnet', + 'go', + 'java', + 'nodejs', + 'python', + 'ruby' +]; -export function isAgentName(agentName: string): boolean { - return Object.values(agentNames).includes(agentName as AgentName); +export function isAgentName(agentName: string): agentName is AgentName { + return AGENT_NAMES.includes(agentName as AgentName); } -export function isRumAgentName(agentName: string | undefined) { - return ( - agentName === agentNames['js-base'] || agentName === agentNames['rum-js'] - ); +export function isRumAgentName( + agentName: string | undefined +): agentName is 'js-base' | 'rum-js' { + return agentName === 'js-base' || agentName === 'rum-js'; } -export function isJavaAgentName(agentName: string | undefined) { - return agentName === agentNames.java; +export function isJavaAgentName( + agentName: string | undefined +): agentName is 'java' { + return agentName === 'java'; } diff --git a/x-pack/plugins/apm/common/apm_saved_object_constants.ts b/x-pack/plugins/apm/common/apm_saved_object_constants.ts index ac43b700117c6..0529d90fe940a 100644 --- a/x-pack/plugins/apm/common/apm_saved_object_constants.ts +++ b/x-pack/plugins/apm/common/apm_saved_object_constants.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -// APM Services telemetry -export const APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE = - 'apm-services-telemetry'; -export const APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID = 'apm-services-telemetry'; +// the types have to match the names of the saved object mappings +// in /x-pack/legacy/plugins/apm/mappings.json // APM indices export const APM_INDICES_SAVED_OBJECT_TYPE = 'apm-indices'; export const APM_INDICES_SAVED_OBJECT_ID = 'apm-indices'; + +// APM telemetry +export const APM_TELEMETRY_SAVED_OBJECT_TYPE = 'apm-telemetry'; +export const APM_TELEMETRY_SAVED_OBJECT_ID = 'apm-telemetry'; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts index 1add2427d16a0..63fa749cd9f2c 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts @@ -15,7 +15,10 @@ describe('Transaction', () => { const transaction: AllowUnknownProperties = { '@timestamp': new Date().toString(), '@metadata': 'whatever', - observer: 'whatever', + observer: { + version: 'whatever', + version_major: 8 + }, agent: { name: 'java', version: 'agent version' @@ -63,7 +66,10 @@ describe('Span', () => { const span: AllowUnknownProperties = { '@timestamp': new Date().toString(), '@metadata': 'whatever', - observer: 'whatever', + observer: { + version: 'whatever', + version_major: 8 + }, agent: { name: 'java', version: 'agent version' @@ -107,7 +113,10 @@ describe('Span', () => { describe('Error', () => { const errorDoc: AllowUnknownProperties = { '@metadata': 'whatever', - observer: 'whatever', + observer: { + version: 'whatever', + version_major: 8 + }, agent: { name: 'java', version: 'agent version' diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index 822201baddd88..bc1b346f50da7 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -4,15 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -export const AGENT_NAME = 'agent.name'; export const SERVICE_NAME = 'service.name'; export const SERVICE_ENVIRONMENT = 'service.environment'; export const SERVICE_FRAMEWORK_NAME = 'service.framework.name'; +export const SERVICE_FRAMEWORK_VERSION = 'service.framework.version'; +export const SERVICE_LANGUAGE_NAME = 'service.language.name'; +export const SERVICE_LANGUAGE_VERSION = 'service.language.version'; +export const SERVICE_RUNTIME_NAME = 'service.runtime.name'; +export const SERVICE_RUNTIME_VERSION = 'service.runtime.version'; export const SERVICE_NODE_NAME = 'service.node.name'; export const SERVICE_VERSION = 'service.version'; + +export const AGENT_NAME = 'agent.name'; +export const AGENT_VERSION = 'agent.version'; + export const URL_FULL = 'url.full'; export const HTTP_REQUEST_METHOD = 'http.request.method'; export const USER_ID = 'user.id'; +export const USER_AGENT_ORIGINAL = 'user_agent.original'; export const USER_AGENT_NAME = 'user_agent.name'; export const DESTINATION_ADDRESS = 'destination.address'; diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 96579377c95e8..dadb1dff6d7a9 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -3,8 +3,11 @@ "server": true, "version": "8.0.0", "kibanaVersion": "kibana", - "configPath": ["xpack", "apm"], + "configPath": [ + "xpack", + "apm" + ], "ui": false, "requiredPlugins": ["apm_oss", "data", "home", "licensing"], - "optionalPlugins": ["cloud", "usageCollection"] + "optionalPlugins": ["cloud", "usageCollection", "taskManager"] } diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 8afdb9e99c1a3..77655568a7e9c 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -29,7 +29,8 @@ export const config = { enabled: schema.boolean({ defaultValue: true }), transactionGroupBucketSize: schema.number({ defaultValue: 100 }), maxTraceItems: schema.number({ defaultValue: 1000 }) - }) + }), + telemetryCollectionEnabled: schema.boolean({ defaultValue: true }) }) }; @@ -62,7 +63,8 @@ export function mergeConfigs( 'xpack.apm.ui.maxTraceItems': apmConfig.ui.maxTraceItems, 'xpack.apm.ui.transactionGroupBucketSize': apmConfig.ui.transactionGroupBucketSize, - 'xpack.apm.autocreateApmIndexPattern': apmConfig.autocreateApmIndexPattern + 'xpack.apm.autocreateApmIndexPattern': apmConfig.autocreateApmIndexPattern, + 'xpack.apm.telemetryCollectionEnabled': apmConfig.telemetryCollectionEnabled }; } diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts deleted file mode 100644 index c45c74a791aee..0000000000000 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts +++ /dev/null @@ -1,83 +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 { SavedObjectAttributes } from '../../../../../../../src/core/server'; -import { createApmTelementry, storeApmServicesTelemetry } from '../index'; -import { - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, - APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID -} from '../../../../common/apm_saved_object_constants'; - -describe('apm_telemetry', () => { - describe('createApmTelementry', () => { - it('should create a ApmTelemetry object with boolean flag and frequency map of the given list of AgentNames', () => { - const apmTelemetry = createApmTelementry([ - 'go', - 'nodejs', - 'go', - 'js-base' - ]); - expect(apmTelemetry.has_any_services).toBe(true); - expect(apmTelemetry.services_per_agent).toMatchObject({ - go: 2, - nodejs: 1, - 'js-base': 1 - }); - }); - it('should ignore undefined or unknown AgentName values', () => { - const apmTelemetry = createApmTelementry([ - 'go', - 'nodejs', - 'go', - 'js-base', - 'example-platform' as any, - undefined as any - ]); - expect(apmTelemetry.services_per_agent).toMatchObject({ - go: 2, - nodejs: 1, - 'js-base': 1 - }); - }); - }); - - describe('storeApmServicesTelemetry', () => { - let apmTelemetry: SavedObjectAttributes; - let savedObjectsClient: any; - - beforeEach(() => { - apmTelemetry = { - has_any_services: true, - services_per_agent: { - go: 2, - nodejs: 1, - 'js-base': 1 - } - }; - savedObjectsClient = { create: jest.fn() }; - }); - - it('should call savedObjectsClient create with the given ApmTelemetry object', () => { - storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); - expect(savedObjectsClient.create.mock.calls[0][1]).toBe(apmTelemetry); - }); - - it('should call savedObjectsClient create with the apm-telemetry document type and ID', () => { - storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); - expect(savedObjectsClient.create.mock.calls[0][0]).toBe( - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE - ); - expect(savedObjectsClient.create.mock.calls[0][2].id).toBe( - APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID - ); - }); - - it('should call savedObjectsClient create with overwrite: true', () => { - storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); - expect(savedObjectsClient.create.mock.calls[0][2].overwrite).toBe(true); - }); - }); -}); diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts new file mode 100644 index 0000000000000..729ccb73d73f3 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts @@ -0,0 +1,77 @@ +/* + * 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 { merge } from 'lodash'; +import { Logger, CallAPIOptions } from 'kibana/server'; +import { IndicesStatsParams, Client } from 'elasticsearch'; +import { + ESSearchRequest, + ESSearchResponse +} from '../../../../typings/elasticsearch'; +import { ApmIndicesConfig } from '../../settings/apm_indices/get_apm_indices'; +import { tasks } from './tasks'; +import { APMDataTelemetry } from '../types'; + +type TelemetryTaskExecutor = (params: { + indices: ApmIndicesConfig; + search( + params: TSearchRequest + ): Promise>; + indicesStats( + params: IndicesStatsParams, + options?: CallAPIOptions + ): ReturnType; + transportRequest: (params: { + path: string; + method: 'get'; + }) => Promise; +}) => Promise; + +export interface TelemetryTask { + name: string; + executor: TelemetryTaskExecutor; +} + +export type CollectTelemetryParams = Parameters[0] & { + logger: Logger; +}; + +export function collectDataTelemetry({ + search, + indices, + logger, + indicesStats, + transportRequest +}: CollectTelemetryParams) { + return tasks.reduce((prev, task) => { + return prev.then(async data => { + logger.debug(`Executing APM telemetry task ${task.name}`); + try { + const time = process.hrtime(); + const next = await task.executor({ + search, + indices, + indicesStats, + transportRequest + }); + const took = process.hrtime(time); + + return merge({}, data, next, { + tasks: { + [task.name]: { + took: { + ms: Math.round(took[0] * 1000 + took[1] / 1e6) + } + } + } + }); + } catch (err) { + logger.warn(`Failed executing APM telemetry task ${task.name}`); + logger.warn(err); + return data; + } + }); + }, Promise.resolve({} as APMDataTelemetry)); +} diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts new file mode 100644 index 0000000000000..415076b6ae116 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts @@ -0,0 +1,725 @@ +/* + * 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 { flatten, merge, sortBy, sum } from 'lodash'; +import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; +import { AGENT_NAMES } from '../../../../common/agent_name'; +import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; +import { + PROCESSOR_EVENT, + SERVICE_NAME, + AGENT_NAME, + AGENT_VERSION, + ERROR_GROUP_ID, + TRANSACTION_NAME, + PARENT_ID, + SERVICE_FRAMEWORK_NAME, + SERVICE_FRAMEWORK_VERSION, + SERVICE_LANGUAGE_NAME, + SERVICE_LANGUAGE_VERSION, + SERVICE_RUNTIME_NAME, + SERVICE_RUNTIME_VERSION, + USER_AGENT_ORIGINAL +} from '../../../../common/elasticsearch_fieldnames'; +import { Span } from '../../../../typings/es_schemas/ui/span'; +import { APMError } from '../../../../typings/es_schemas/ui/apm_error'; +import { TelemetryTask } from '.'; +import { APMTelemetry } from '../types'; + +const TIME_RANGES = ['1d', 'all'] as const; +type TimeRange = typeof TIME_RANGES[number]; + +export const tasks: TelemetryTask[] = [ + { + name: 'processor_events', + executor: async ({ indices, search }) => { + const indicesByProcessorEvent = { + error: indices['apm_oss.errorIndices'], + metric: indices['apm_oss.metricsIndices'], + span: indices['apm_oss.spanIndices'], + transaction: indices['apm_oss.transactionIndices'], + onboarding: indices['apm_oss.onboardingIndices'], + sourcemap: indices['apm_oss.sourcemapIndices'] + }; + + type ProcessorEvent = keyof typeof indicesByProcessorEvent; + + const jobs: Array<{ + processorEvent: ProcessorEvent; + timeRange: TimeRange; + }> = flatten( + (Object.keys( + indicesByProcessorEvent + ) as ProcessorEvent[]).map(processorEvent => + TIME_RANGES.map(timeRange => ({ processorEvent, timeRange })) + ) + ); + + const allData = await jobs.reduce((prevJob, current) => { + return prevJob.then(async data => { + const { processorEvent, timeRange } = current; + + const response = await search({ + index: indicesByProcessorEvent[processorEvent], + body: { + size: 1, + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: processorEvent } }, + ...(timeRange !== 'all' + ? [ + { + range: { + '@timestamp': { + gte: `now-${timeRange}` + } + } + } + ] + : []) + ] + } + }, + sort: { + '@timestamp': 'asc' + }, + _source: ['@timestamp'], + track_total_hits: true + } + }); + + const event = response.hits.hits[0]?._source as { + '@timestamp': number; + }; + + return merge({}, data, { + counts: { + [processorEvent]: { + [timeRange]: response.hits.total.value + } + }, + ...(timeRange === 'all' && event + ? { + retainment: { + [processorEvent]: { + ms: + new Date().getTime() - + new Date(event['@timestamp']).getTime() + } + } + } + : {}) + }); + }); + }, Promise.resolve({} as Record> }>)); + + return allData; + } + }, + { + name: 'agent_configuration', + executor: async ({ indices, search }) => { + const agentConfigurationCount = ( + await search({ + index: indices.apmAgentConfigurationIndex, + body: { + size: 0, + track_total_hits: true + } + }) + ).hits.total.value; + + return { + counts: { + agent_configuration: { + all: agentConfigurationCount + } + } + }; + } + }, + { + name: 'services', + executor: async ({ indices, search }) => { + const servicesPerAgent = await AGENT_NAMES.reduce( + (prevJob, agentName) => { + return prevJob.then(async data => { + const response = await search({ + index: [ + indices['apm_oss.errorIndices'], + indices['apm_oss.spanIndices'], + indices['apm_oss.metricsIndices'], + indices['apm_oss.transactionIndices'] + ], + body: { + size: 0, + query: { + bool: { + filter: [ + { + term: { + [AGENT_NAME]: agentName + } + }, + { + range: { + '@timestamp': { + gte: 'now-1d' + } + } + } + ] + } + }, + aggs: { + services: { + cardinality: { + field: SERVICE_NAME + } + } + } + } + }); + + return { + ...data, + [agentName]: response.aggregations?.services.value || 0 + }; + }); + }, + Promise.resolve({} as Record) + ); + + return { + has_any_services: sum(Object.values(servicesPerAgent)) > 0, + services_per_agent: servicesPerAgent + }; + } + }, + { + name: 'versions', + executor: async ({ search, indices }) => { + const response = await search({ + index: [ + indices['apm_oss.transactionIndices'], + indices['apm_oss.spanIndices'], + indices['apm_oss.errorIndices'] + ], + terminateAfter: 1, + body: { + query: { + exists: { + field: 'observer.version' + } + }, + size: 1, + sort: { + '@timestamp': 'desc' + } + } + }); + + const hit = response.hits.hits[0]?._source as Pick< + Transaction | Span | APMError, + 'observer' + >; + + if (!hit || !hit.observer?.version) { + return {}; + } + + const [major, minor, patch] = hit.observer.version + .split('.') + .map(part => Number(part)); + + return { + versions: { + apm_server: { + major, + minor, + patch + } + } + }; + } + }, + { + name: 'groupings', + executor: async ({ search, indices }) => { + const range1d = { range: { '@timestamp': { gte: 'now-1d' } } }; + const errorGroupsCount = ( + await search({ + index: indices['apm_oss.errorIndices'], + body: { + size: 0, + query: { + bool: { + filter: [{ term: { [PROCESSOR_EVENT]: 'error' } }, range1d] + } + }, + aggs: { + top_service: { + terms: { + field: SERVICE_NAME, + order: { + error_groups: 'desc' + }, + size: 1 + }, + aggs: { + error_groups: { + cardinality: { + field: ERROR_GROUP_ID + } + } + } + } + } + } + }) + ).aggregations?.top_service.buckets[0]?.error_groups.value; + + const transactionGroupsCount = ( + await search({ + index: indices['apm_oss.transactionIndices'], + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: 'transaction' } }, + range1d + ] + } + }, + aggs: { + top_service: { + terms: { + field: SERVICE_NAME, + order: { + transaction_groups: 'desc' + }, + size: 1 + }, + aggs: { + transaction_groups: { + cardinality: { + field: TRANSACTION_NAME + } + } + } + } + } + } + }) + ).aggregations?.top_service.buckets[0]?.transaction_groups.value; + + const tracesPerDayCount = ( + await search({ + index: indices['apm_oss.transactionIndices'], + body: { + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: 'transaction' } }, + range1d + ], + must_not: { + exists: { field: PARENT_ID } + } + } + }, + track_total_hits: true, + size: 0 + } + }) + ).hits.total.value; + + const servicesCount = ( + await search({ + index: [ + indices['apm_oss.transactionIndices'], + indices['apm_oss.errorIndices'], + indices['apm_oss.metricsIndices'] + ], + body: { + size: 0, + query: { + bool: { + filter: [range1d] + } + }, + aggs: { + service_name: { + cardinality: { + field: SERVICE_NAME + } + } + } + } + }) + ).aggregations?.service_name.value; + + return { + counts: { + max_error_groups_per_service: { + '1d': errorGroupsCount || 0 + }, + max_transaction_groups_per_service: { + '1d': transactionGroupsCount || 0 + }, + traces: { + '1d': tracesPerDayCount || 0 + }, + services: { + '1d': servicesCount || 0 + } + } + }; + } + }, + { + name: 'integrations', + executor: async ({ transportRequest }) => { + const apmJobs = ['*-high_mean_response_time']; + + const response = (await transportRequest({ + method: 'get', + path: `/_ml/anomaly_detectors/${apmJobs.join(',')}` + })) as { data?: { count: number } }; + + return { + integrations: { + ml: { + all_jobs_count: response.data?.count ?? 0 + } + } + }; + } + }, + { + name: 'agents', + executor: async ({ search, indices }) => { + const size = 3; + + const agentData = await AGENT_NAMES.reduce(async (prevJob, agentName) => { + const data = await prevJob; + + const response = await search({ + index: [ + indices['apm_oss.errorIndices'], + indices['apm_oss.metricsIndices'], + indices['apm_oss.transactionIndices'] + ], + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [AGENT_NAME]: agentName } }, + { range: { '@timestamp': { gte: 'now-1d' } } } + ] + } + }, + sort: { + '@timestamp': 'desc' + }, + aggs: { + [AGENT_VERSION]: { + terms: { + field: AGENT_VERSION, + size + } + }, + [SERVICE_FRAMEWORK_NAME]: { + terms: { + field: SERVICE_FRAMEWORK_NAME, + size + }, + aggs: { + [SERVICE_FRAMEWORK_VERSION]: { + terms: { + field: SERVICE_FRAMEWORK_VERSION, + size + } + } + } + }, + [SERVICE_FRAMEWORK_VERSION]: { + terms: { + field: SERVICE_FRAMEWORK_VERSION, + size + } + }, + [SERVICE_LANGUAGE_NAME]: { + terms: { + field: SERVICE_LANGUAGE_NAME, + size + }, + aggs: { + [SERVICE_LANGUAGE_VERSION]: { + terms: { + field: SERVICE_LANGUAGE_VERSION, + size + } + } + } + }, + [SERVICE_LANGUAGE_VERSION]: { + terms: { + field: SERVICE_LANGUAGE_VERSION, + size + } + }, + [SERVICE_RUNTIME_NAME]: { + terms: { + field: SERVICE_RUNTIME_NAME, + size + }, + aggs: { + [SERVICE_RUNTIME_VERSION]: { + terms: { + field: SERVICE_RUNTIME_VERSION, + size + } + } + } + }, + [SERVICE_RUNTIME_VERSION]: { + terms: { + field: SERVICE_RUNTIME_VERSION, + size + } + } + } + } + }); + + const { aggregations } = response; + + if (!aggregations) { + return data; + } + + const toComposite = ( + outerKey: string | number, + innerKey: string | number + ) => `${outerKey}/${innerKey}`; + + return { + ...data, + [agentName]: { + agent: { + version: aggregations[AGENT_VERSION].buckets.map( + bucket => bucket.key as string + ) + }, + service: { + framework: { + name: aggregations[SERVICE_FRAMEWORK_NAME].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + version: aggregations[SERVICE_FRAMEWORK_VERSION].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + composite: sortBy( + flatten( + aggregations[SERVICE_FRAMEWORK_NAME].buckets.map(bucket => + bucket[SERVICE_FRAMEWORK_VERSION].buckets.map( + versionBucket => ({ + doc_count: versionBucket.doc_count, + name: toComposite(bucket.key, versionBucket.key) + }) + ) + ) + ), + 'doc_count' + ) + .reverse() + .slice(0, size) + .map(composite => composite.name) + }, + language: { + name: aggregations[SERVICE_LANGUAGE_NAME].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + version: aggregations[SERVICE_LANGUAGE_VERSION].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + composite: sortBy( + flatten( + aggregations[SERVICE_LANGUAGE_NAME].buckets.map(bucket => + bucket[SERVICE_LANGUAGE_VERSION].buckets.map( + versionBucket => ({ + doc_count: versionBucket.doc_count, + name: toComposite(bucket.key, versionBucket.key) + }) + ) + ) + ), + 'doc_count' + ) + .reverse() + .slice(0, size) + .map(composite => composite.name) + }, + runtime: { + name: aggregations[SERVICE_RUNTIME_NAME].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + version: aggregations[SERVICE_RUNTIME_VERSION].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + composite: sortBy( + flatten( + aggregations[SERVICE_RUNTIME_NAME].buckets.map(bucket => + bucket[SERVICE_RUNTIME_VERSION].buckets.map( + versionBucket => ({ + doc_count: versionBucket.doc_count, + name: toComposite(bucket.key, versionBucket.key) + }) + ) + ) + ), + 'doc_count' + ) + .reverse() + .slice(0, size) + .map(composite => composite.name) + } + } + } + }; + }, Promise.resolve({} as APMTelemetry['agents'])); + + return { + agents: agentData + }; + } + }, + { + name: 'indices_stats', + executor: async ({ indicesStats, indices }) => { + const response = await indicesStats({ + index: [ + indices.apmAgentConfigurationIndex, + indices['apm_oss.errorIndices'], + indices['apm_oss.metricsIndices'], + indices['apm_oss.onboardingIndices'], + indices['apm_oss.sourcemapIndices'], + indices['apm_oss.spanIndices'], + indices['apm_oss.transactionIndices'] + ] + }); + + return { + indices: { + shards: { + total: response._shards.total + }, + all: { + total: { + docs: { + count: response._all.total.docs.count + }, + store: { + size_in_bytes: response._all.total.store.size_in_bytes + } + } + } + } + }; + } + }, + { + name: 'cardinality', + executor: async ({ search }) => { + const allAgentsCardinalityResponse = await search({ + body: { + size: 0, + query: { + bool: { + filter: [{ range: { '@timestamp': { gte: 'now-1d' } } }] + } + }, + aggs: { + [TRANSACTION_NAME]: { + cardinality: { + field: TRANSACTION_NAME + } + }, + [USER_AGENT_ORIGINAL]: { + cardinality: { + field: USER_AGENT_ORIGINAL + } + } + } + } + }); + + const rumAgentCardinalityResponse = await search({ + body: { + size: 0, + query: { + bool: { + filter: [ + { range: { '@timestamp': { gte: 'now-1d' } } }, + { terms: { [AGENT_NAME]: ['rum-js', 'js-base'] } } + ] + } + }, + aggs: { + [TRANSACTION_NAME]: { + cardinality: { + field: TRANSACTION_NAME + } + }, + [USER_AGENT_ORIGINAL]: { + cardinality: { + field: USER_AGENT_ORIGINAL + } + } + } + } + }); + + return { + cardinality: { + transaction: { + name: { + all_agents: { + '1d': + allAgentsCardinalityResponse.aggregations?.[TRANSACTION_NAME] + .value + }, + rum: { + '1d': + rumAgentCardinalityResponse.aggregations?.[TRANSACTION_NAME] + .value + } + } + }, + user_agent: { + original: { + all_agents: { + '1d': + allAgentsCardinalityResponse.aggregations?.[ + USER_AGENT_ORIGINAL + ].value + }, + rum: { + '1d': + rumAgentCardinalityResponse.aggregations?.[ + USER_AGENT_ORIGINAL + ].value + } + } + } + } + }; + } + } +]; diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts index a2b0494730826..c80057a2894dc 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts @@ -3,60 +3,127 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { countBy } from 'lodash'; -import { SavedObjectAttributes } from '../../../../../../src/core/server'; -import { isAgentName } from '../../../common/agent_name'; +import { CoreSetup, Logger } from 'src/core/server'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { + TaskManagerStartContract, + TaskManagerSetupContract +} from '../../../../task_manager/server'; +import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; import { - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, - APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID + APM_TELEMETRY_SAVED_OBJECT_ID, + APM_TELEMETRY_SAVED_OBJECT_TYPE } from '../../../common/apm_saved_object_constants'; -import { UsageCollectionSetup } from '../../../../../../src/plugins/usage_collection/server'; -import { InternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; - -export function createApmTelementry( - agentNames: string[] = [] -): SavedObjectAttributes { - const validAgentNames = agentNames.filter(isAgentName); - return { - has_any_services: validAgentNames.length > 0, - services_per_agent: countBy(validAgentNames) +import { + collectDataTelemetry, + CollectTelemetryParams +} from './collect_data_telemetry'; +import { APMConfig } from '../..'; +import { getInternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; + +const APM_TELEMETRY_TASK_NAME = 'apm-telemetry-task'; + +export async function createApmTelemetry({ + core, + config$, + usageCollector, + taskManager, + logger +}: { + core: CoreSetup; + config$: Observable; + usageCollector: UsageCollectionSetup; + taskManager: TaskManagerSetupContract; + logger: Logger; +}) { + const savedObjectsClient = await getInternalSavedObjectsClient(core); + + const collectAndStore = async () => { + const config = await config$.pipe(take(1)).toPromise(); + const esClient = core.elasticsearch.dataClient; + + const indices = await getApmIndices({ + config, + savedObjectsClient + }); + + const search = esClient.callAsInternalUser.bind( + esClient, + 'search' + ) as CollectTelemetryParams['search']; + + const indicesStats = esClient.callAsInternalUser.bind( + esClient, + 'indices.stats' + ) as CollectTelemetryParams['indicesStats']; + + const transportRequest = esClient.callAsInternalUser.bind( + esClient, + 'transport.request' + ) as CollectTelemetryParams['transportRequest']; + + const dataTelemetry = await collectDataTelemetry({ + search, + indices, + logger, + indicesStats, + transportRequest + }); + + await savedObjectsClient.create( + APM_TELEMETRY_SAVED_OBJECT_TYPE, + dataTelemetry, + { id: APM_TELEMETRY_SAVED_OBJECT_TYPE, overwrite: true } + ); }; -} -export async function storeApmServicesTelemetry( - savedObjectsClient: InternalSavedObjectsClient, - apmTelemetry: SavedObjectAttributes -) { - return savedObjectsClient.create( - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, - apmTelemetry, - { - id: APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID, - overwrite: true + taskManager.registerTaskDefinitions({ + [APM_TELEMETRY_TASK_NAME]: { + title: 'Collect APM telemetry', + type: APM_TELEMETRY_TASK_NAME, + createTaskRunner: () => { + return { + run: async () => { + await collectAndStore(); + } + }; + } } - ); -} + }); -export function makeApmUsageCollector( - usageCollector: UsageCollectionSetup, - savedObjectsRepository: InternalSavedObjectsClient -) { - const apmUsageCollector = usageCollector.makeUsageCollector({ + const collector = usageCollector.makeUsageCollector({ type: 'apm', fetch: async () => { - try { - const apmTelemetrySavedObject = await savedObjectsRepository.get( - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, - APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID - ); - return apmTelemetrySavedObject.attributes; - } catch (err) { - return createApmTelementry(); - } + const data = ( + await savedObjectsClient.get( + APM_TELEMETRY_SAVED_OBJECT_TYPE, + APM_TELEMETRY_SAVED_OBJECT_ID + ) + ).attributes; + + return data; }, isReady: () => true }); - usageCollector.registerCollector(apmUsageCollector); + usageCollector.registerCollector(collector); + + core.getStartServices().then(([coreStart, pluginsStart]) => { + const { taskManager: taskManagerStart } = pluginsStart as { + taskManager: TaskManagerStartContract; + }; + + taskManagerStart.ensureScheduled({ + id: APM_TELEMETRY_TASK_NAME, + taskType: APM_TELEMETRY_TASK_NAME, + schedule: { + interval: '720m' + }, + scope: ['apm'], + params: {}, + state: {} + }); + }); } diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts new file mode 100644 index 0000000000000..f68dc517a2227 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts @@ -0,0 +1,118 @@ +/* + * 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 { DeepPartial } from 'utility-types'; +import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; + +export interface TimeframeMap { + '1d': number; + all: number; +} + +export type TimeframeMap1d = Pick; +export type TimeframeMapAll = Pick; + +export type APMDataTelemetry = DeepPartial<{ + has_any_services: boolean; + services_per_agent: Record; + versions: { + apm_server: { + minor: number; + major: number; + patch: number; + }; + }; + counts: { + transaction: TimeframeMap; + span: TimeframeMap; + error: TimeframeMap; + metric: TimeframeMap; + sourcemap: TimeframeMap; + onboarding: TimeframeMap; + agent_configuration: TimeframeMapAll; + max_transaction_groups_per_service: TimeframeMap; + max_error_groups_per_service: TimeframeMap; + traces: TimeframeMap; + services: TimeframeMap; + }; + cardinality: { + user_agent: { + original: { + all_agents: TimeframeMap1d; + rum: TimeframeMap1d; + }; + }; + transaction: { + name: { + all_agents: TimeframeMap1d; + rum: TimeframeMap1d; + }; + }; + }; + retainment: Record< + 'span' | 'transaction' | 'error' | 'metric' | 'sourcemap' | 'onboarding', + { ms: number } + >; + integrations: { + ml: { + all_jobs_count: number; + }; + }; + agents: Record< + AgentName, + { + agent: { + version: string[]; + }; + service: { + framework: { + name: string[]; + version: string[]; + composite: string[]; + }; + language: { + name: string[]; + version: string[]; + composite: string[]; + }; + runtime: { + name: string[]; + version: string[]; + composite: string[]; + }; + }; + } + >; + indices: { + shards: { + total: number; + }; + all: { + total: { + docs: { + count: number; + }; + store: { + size_in_bytes: number; + }; + }; + }; + }; + tasks: Record< + | 'processor_events' + | 'agent_configuration' + | 'services' + | 'versions' + | 'groupings' + | 'integrations' + | 'agents' + | 'indices_stats' + | 'cardinality', + { took: { ms: number } } + >; +}>; + +export type APMTelemetry = APMDataTelemetry; diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index 40a2a0e7216a0..8e8cf698a84cf 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -39,6 +39,19 @@ function getMockRequest() { _debug: false } }, + __LEGACY: { + server: { + plugins: { + elasticsearch: { + getCluster: jest.fn().mockReturnValue({ callWithInternalUser: {} }) + } + }, + savedObjects: { + SavedObjectsClient: jest.fn(), + getSavedObjectsRepository: jest.fn() + } + } + }, core: { elasticsearch: { dataClient: { diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index db14730f802a9..a29b9399d8435 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -8,9 +8,9 @@ import { Observable, combineLatest, AsyncSubject } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { Server } from 'hapi'; import { once } from 'lodash'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; +import { TaskManagerSetupContract } from '../../task_manager/server'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; -import { makeApmUsageCollector } from './lib/apm_telemetry'; import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; import { createApmApi } from './routes/create_apm_api'; @@ -21,6 +21,7 @@ import { tutorialProvider } from './tutorial'; import { CloudSetup } from '../../cloud/server'; import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; import { LicensingPluginSetup } from '../../licensing/public'; +import { createApmTelemetry } from './lib/apm_telemetry'; export interface LegacySetup { server: Server; @@ -47,9 +48,10 @@ export class APMPlugin implements Plugin { licensing: LicensingPluginSetup; cloud?: CloudSetup; usageCollection?: UsageCollectionSetup; + taskManager?: TaskManagerSetupContract; } ) { - const logger = this.initContext.logger.get('apm'); + const logger = this.initContext.logger.get(); const config$ = this.initContext.config.create(); const mergedConfig$ = combineLatest(plugins.apm_oss.config$, config$).pipe( map(([apmOssConfig, apmConfig]) => mergeConfigs(apmOssConfig, apmConfig)) @@ -61,6 +63,20 @@ export class APMPlugin implements Plugin { const currentConfig = await mergedConfig$.pipe(take(1)).toPromise(); + if ( + plugins.taskManager && + plugins.usageCollection && + currentConfig['xpack.apm.telemetryCollectionEnabled'] + ) { + createApmTelemetry({ + core, + config$: mergedConfig$, + usageCollector: plugins.usageCollection, + taskManager: plugins.taskManager, + logger + }); + } + // create agent configuration index without blocking setup lifecycle createApmAgentConfigurationIndex({ esClient: core.elasticsearch.dataClient, @@ -89,18 +105,6 @@ export class APMPlugin implements Plugin { }) ); - const usageCollection = plugins.usageCollection; - if (usageCollection) { - getInternalSavedObjectsClient(core) - .then(savedObjectsClient => { - makeApmUsageCollector(usageCollection, savedObjectsClient); - }) - .catch(error => { - logger.error('Unable to initialize use collection'); - logger.error(error.message); - }); - } - return { config$: mergedConfig$, registerLegacyAPI: once((__LEGACY: LegacySetup) => { @@ -115,6 +119,7 @@ export class APMPlugin implements Plugin { }; } - public start() {} + public async start() {} + public stop() {} } diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts index e639bb5101e2f..312dae1d1f9d2 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.test.ts @@ -36,6 +36,7 @@ const getCoreMock = () => { put, createRouter, context: { + measure: () => undefined, config$: new BehaviorSubject({} as APMConfig), logger: ({ error: jest.fn() diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 2d4fae9d2707a..1c6561ee24c93 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -5,11 +5,6 @@ */ import * as t from 'io-ts'; -import { AgentName } from '../../typings/es_schemas/ui/fields/agent'; -import { - createApmTelementry, - storeApmServicesTelemetry -} from '../lib/apm_telemetry'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceAgentName } from '../lib/services/get_service_agent_name'; import { getServices } from '../lib/services/get_services'; @@ -18,7 +13,6 @@ import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadat import { createRoute } from './create_route'; import { uiFiltersRt, rangeRt } from './default_api_types'; import { getServiceAnnotations } from '../lib/services/annotations'; -import { getInternalSavedObjectsClient } from '../lib/helpers/get_internal_saved_objects_client'; export const servicesRoute = createRoute(core => ({ path: '/api/apm/services', @@ -29,16 +23,6 @@ export const servicesRoute = createRoute(core => ({ const setup = await setupRequest(context, request); const services = await getServices(setup); - // Store telemetry data derived from services - const agentNames = services.items.map( - ({ agentName }) => agentName as AgentName - ); - const apmTelemetry = createApmTelementry(agentNames); - const savedObjectsClient = await getInternalSavedObjectsClient(core); - storeApmServicesTelemetry(savedObjectsClient, apmTelemetry).catch(error => { - context.logger.error(error.message); - }); - return services; } })); diff --git a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts index 6d3620f11a87b..8a8d256cf4273 100644 --- a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts +++ b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts @@ -126,6 +126,16 @@ export interface AggregationOptionsByType { combine_script: Script; reduce_script: Script; }; + date_range: { + field: string; + format?: string; + ranges: Array< + | { from: string | number } + | { to: string | number } + | { from: string | number; to: string | number } + >; + keyed?: boolean; + }; } type AggregationType = keyof AggregationOptionsByType; @@ -136,6 +146,15 @@ type AggregationOptionsMap = Unionize< } > & { aggs?: AggregationInputMap }; +interface DateRangeBucket { + key: string; + to?: number; + from?: number; + to_as_string?: string; + from_as_string?: string; + doc_count: number; +} + export interface AggregationInputMap { [key: string]: AggregationOptionsMap; } @@ -276,6 +295,11 @@ interface AggregationResponsePart< scripted_metric: { value: unknown; }; + date_range: { + buckets: TAggregationOptionsMap extends { date_range: { keyed: true } } + ? Record + : { buckets: DateRangeBucket[] }; + }; } // Type for debugging purposes. If you see an error in AggregationResponseMap @@ -285,7 +309,7 @@ interface AggregationResponsePart< // type MissingAggregationResponseTypes = Exclude< // AggregationType, -// keyof AggregationResponsePart<{}> +// keyof AggregationResponsePart<{}, unknown> // >; export type AggregationResponseMap< diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts index daf65e44980b6..8e49d02beb908 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts @@ -15,6 +15,7 @@ import { Service } from './fields/service'; import { IStackframe } from './fields/stackframe'; import { Url } from './fields/url'; import { User } from './fields/user'; +import { Observer } from './fields/observer'; interface Processor { name: 'error'; @@ -61,4 +62,5 @@ export interface ErrorRaw extends APMBaseDoc { service: Service; url?: Url; user?: User; + observer?: Observer; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts new file mode 100644 index 0000000000000..42843130ec47f --- /dev/null +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts @@ -0,0 +1,10 @@ +/* + * 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 Observer { + version: string; + version_major: number; +} diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts index dbd9e7ede4256..4d5d2c5c4a12e 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts @@ -6,6 +6,7 @@ import { APMBaseDoc } from './apm_base_doc'; import { IStackframe } from './fields/stackframe'; +import { Observer } from './fields/observer'; interface Processor { name: 'transaction'; @@ -50,4 +51,5 @@ export interface SpanRaw extends APMBaseDoc { transaction?: { id: string; }; + observer?: Observer; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts index 3673f1f13c403..b8ebb4cf8da51 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts @@ -15,6 +15,7 @@ import { Service } from './fields/service'; import { Url } from './fields/url'; import { User } from './fields/user'; import { UserAgent } from './fields/user_agent'; +import { Observer } from './fields/observer'; interface Processor { name: 'transaction'; @@ -61,4 +62,5 @@ export interface TransactionRaw extends APMBaseDoc { url?: Url; user?: User; user_agent?: UserAgent; + observer?: Observer; } From dc31736dd28187750fd3cd51ed3aae7dc230179c Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 23 Mar 2020 16:40:43 -0600 Subject: [PATCH 047/179] [Maps] Default ES document layer scaling type to clusters and show scaling UI in the create wizard (#60668) * [Maps] show scaling panel in ES documents create wizard * minor fix * remove unused async state * update create editor to use ScalingForm * default geo field * ts lint errors * remove old dynamic filter behavior * update jest tests * eslint * remove indexCount route Co-authored-by: Elastic Machine --- .../maps/public/actions/map_actions.d.ts | 8 + .../layer_panel/view.d.ts | 14 + .../__snapshots__/scaling_form.test.tsx.snap | 205 ++++++++++++ .../update_source_editor.test.js.snap | 315 +----------------- .../es_search_source/create_source_editor.js | 189 +++++------ .../es_search_source/scaling_form.test.tsx | 47 +++ .../sources/es_search_source/scaling_form.tsx | 230 +++++++++++++ .../es_search_source/update_source_editor.js | 196 +---------- .../update_source_editor.test.js | 8 - x-pack/legacy/plugins/maps/server/routes.js | 20 -- x-pack/plugins/maps/common/constants.ts | 14 +- .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - 13 files changed, 636 insertions(+), 616 deletions(-) create mode 100644 x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.d.ts create mode 100644 x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap create mode 100644 x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.test.tsx create mode 100644 x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx diff --git a/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts b/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts index 418f2880c1077..3a61d5affd861 100644 --- a/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts +++ b/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts @@ -5,6 +5,7 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ +import { LAYER_TYPE } from '../../common/constants'; import { DataMeta, MapFilters } from '../../common/data_request_descriptor_types'; export type SyncContext = { @@ -16,3 +17,10 @@ export type SyncContext = { registerCancelCallback(requestToken: symbol, callback: () => void): void; dataFilters: MapFilters; }; + +export function updateSourceProp( + layerId: string, + propName: string, + value: unknown, + newLayerType?: LAYER_TYPE +): void; diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.d.ts b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.d.ts new file mode 100644 index 0000000000000..6d1d076c723ad --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.d.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ + +import { LAYER_TYPE } from '../../../common/constants'; + +export type OnSourceChangeArgs = { + propName: string; + value: unknown; + newLayerType?: LAYER_TYPE; +}; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap new file mode 100644 index 0000000000000..967225d6f0fdc --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap @@ -0,0 +1,205 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should not render clusters option when clustering is not supported 1`] = ` + + +
+ +
+
+ + + + + + + +
+`; + +exports[`should render 1`] = ` + + +
+ +
+
+ + + + + + + +
+`; + +exports[`should render top hits form when scaling type is TOP_HITS 1`] = ` + + +
+ +
+
+ + + + + + + + + + + +
+`; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap index c94f305773f35..0cb7f67fb9c92 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap @@ -91,253 +91,16 @@ exports[`should enable sort order select when sort field provided 1`] = ` size="s" /> - -
- -
-
- - - - - - - -
- - -`; - -exports[`should render top hits form when scaling type is TOP_HITS 1`] = ` - - - -
- -
-
- - -
- - - -
- -
-
- - - - - - - -
- - - -
- -
-
- - - - - - - - - - - - - -
- -
- -
-
- - - - - - -
{ @@ -34,11 +27,26 @@ function getGeoFields(fields) { ); }); } + +function isGeoFieldAggregatable(indexPattern, geoFieldName) { + if (!indexPattern) { + return false; + } + + const geoField = indexPattern.fields.getByName(geoFieldName); + return geoField && geoField.aggregatable; +} + const RESET_INDEX_PATTERN_STATE = { indexPattern: undefined, - geoField: undefined, + geoFields: undefined, + + // ES search source descriptor state + geoFieldName: undefined, filterByMapBounds: DEFAULT_FILTER_BY_MAP_BOUNDS, - showFilterByBoundsSwitch: false, + scalingType: SCALING_TYPES.CLUSTERS, // turn on clusting by default + topHitsSplitField: undefined, + topHitsSize: 1, }; export class CreateSourceEditor extends Component { @@ -58,41 +66,28 @@ export class CreateSourceEditor extends Component { componentDidMount() { this._isMounted = true; - this.loadIndexPattern(this.state.indexPatternId); } - onIndexPatternSelect = indexPatternId => { + _onIndexPatternSelect = indexPatternId => { this.setState( { indexPatternId, }, - this.loadIndexPattern(indexPatternId) + this._loadIndexPattern(indexPatternId) ); }; - loadIndexPattern = indexPatternId => { + _loadIndexPattern = indexPatternId => { this.setState( { isLoadingIndexPattern: true, ...RESET_INDEX_PATTERN_STATE, }, - this.debouncedLoad.bind(null, indexPatternId) + this._debouncedLoad.bind(null, indexPatternId) ); }; - loadIndexDocCount = async indexPatternTitle => { - const http = getHttp(); - const { count } = await http.fetch(`../${GIS_API_PATH}/indexCount`, { - method: 'GET', - credentials: 'same-origin', - query: { - index: indexPatternTitle, - }, - }); - return count; - }; - - debouncedLoad = _.debounce(async indexPatternId => { + _debouncedLoad = _.debounce(async indexPatternId => { if (!indexPatternId || indexPatternId.length === 0) { return; } @@ -105,15 +100,6 @@ export class CreateSourceEditor extends Component { return; } - let indexHasSmallDocCount = false; - try { - const indexDocCount = await this.loadIndexDocCount(indexPattern.title); - indexHasSmallDocCount = indexDocCount <= DEFAULT_MAX_RESULT_WINDOW; - } catch (error) { - // retrieving index count is a nice to have and is not essential - // do not interrupt user flow if unable to retrieve count - } - if (!this._isMounted) { return; } @@ -124,43 +110,71 @@ export class CreateSourceEditor extends Component { return; } + const geoFields = getGeoFields(indexPattern.fields); this.setState({ isLoadingIndexPattern: false, indexPattern: indexPattern, - filterByMapBounds: !indexHasSmallDocCount, // Turn off filterByMapBounds when index contains a limited number of documents - showFilterByBoundsSwitch: indexHasSmallDocCount, + geoFields, }); - //make default selection - const geoFields = getGeoFields(indexPattern.fields); - if (geoFields[0]) { - this.onGeoFieldSelect(geoFields[0].name); + if (geoFields.length) { + // make default selection, prefer aggregatable field over the first available + const firstAggregatableGeoField = geoFields.find(geoField => { + return geoField.aggregatable; + }); + const defaultGeoFieldName = firstAggregatableGeoField + ? firstAggregatableGeoField + : geoFields[0]; + this._onGeoFieldSelect(defaultGeoFieldName.name); } }, 300); - onGeoFieldSelect = geoField => { + _onGeoFieldSelect = geoFieldName => { + // Respect previous scaling type selection unless newly selected geo field does not support clustering. + const scalingType = + this.state.scalingType === SCALING_TYPES.CLUSTERS && + !isGeoFieldAggregatable(this.state.indexPattern, geoFieldName) + ? SCALING_TYPES.LIMIT + : this.state.scalingType; this.setState( { - geoField, + geoFieldName, + scalingType, }, - this.previewLayer + this._previewLayer ); }; - onFilterByMapBoundsChange = event => { + _onScalingPropChange = ({ propName, value }) => { this.setState( { - filterByMapBounds: event.target.checked, + [propName]: value, }, - this.previewLayer + this._previewLayer ); }; - previewLayer = () => { - const { indexPatternId, geoField, filterByMapBounds } = this.state; + _previewLayer = () => { + const { + indexPatternId, + geoFieldName, + filterByMapBounds, + scalingType, + topHitsSplitField, + topHitsSize, + } = this.state; const sourceConfig = - indexPatternId && geoField ? { indexPatternId, geoField, filterByMapBounds } : null; + indexPatternId && geoFieldName + ? { + indexPatternId, + geoField: geoFieldName, + filterByMapBounds, + scalingType, + topHitsSplitField, + topHitsSize, + } + : null; this.props.onSourceConfigChange(sourceConfig); }; @@ -183,56 +197,35 @@ export class CreateSourceEditor extends Component { placeholder={i18n.translate('xpack.maps.source.esSearch.selectLabel', { defaultMessage: 'Select geo field', })} - value={this.state.geoField} - onChange={this.onGeoFieldSelect} - fields={ - this.state.indexPattern ? getGeoFields(this.state.indexPattern.fields) : undefined - } + value={this.state.geoFieldName} + onChange={this._onGeoFieldSelect} + fields={this.state.geoFields} /> ); } - _renderFilterByMapBounds() { - if (!this.state.showFilterByBoundsSwitch) { + _renderScalingPanel() { + if (!this.state.indexPattern || !this.state.geoFieldName) { return null; } return ( - -

- -

-

- -

-
- - - - + +
); } @@ -265,7 +258,7 @@ export class CreateSourceEditor extends Component { ); } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.test.tsx b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.test.tsx new file mode 100644 index 0000000000000..03f29685891ec --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.test.tsx @@ -0,0 +1,47 @@ +/* + * 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. + */ + +jest.mock('../../../kibana_services', () => ({})); + +jest.mock('./load_index_settings', () => ({ + loadIndexSettings: async () => { + return { maxInnerResultWindow: 100, maxResultWindow: 10000 }; + }, +})); + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { ScalingForm } from './scaling_form'; +import { SCALING_TYPES } from '../../../../common/constants'; + +const defaultProps = { + filterByMapBounds: true, + indexPatternId: 'myIndexPattern', + onChange: () => {}, + scalingType: SCALING_TYPES.LIMIT, + supportsClustering: true, + termFields: [], + topHitsSize: 1, +}; + +test('should render', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should not render clusters option when clustering is not supported', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should render top hits form when scaling type is TOP_HITS', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx new file mode 100644 index 0000000000000..c5950f1132974 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx @@ -0,0 +1,230 @@ +/* + * 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, { Fragment, Component } from 'react'; +import { + EuiFormRow, + EuiSwitch, + EuiSwitchEvent, + EuiTitle, + EuiSpacer, + EuiHorizontalRule, + EuiRadioGroup, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +// @ts-ignore +import { SingleFieldSelect } from '../../../components/single_field_select'; + +// @ts-ignore +import { indexPatternService } from '../../../kibana_services'; +// @ts-ignore +import { getTermsFields, getSourceFields } from '../../../index_pattern_util'; +// @ts-ignore +import { ValidatedRange } from '../../../components/validated_range'; +import { + DEFAULT_MAX_INNER_RESULT_WINDOW, + DEFAULT_MAX_RESULT_WINDOW, + SCALING_TYPES, + LAYER_TYPE, +} from '../../../../common/constants'; +// @ts-ignore +import { loadIndexSettings } from './load_index_settings'; +import { IFieldType } from '../../../../../../../../src/plugins/data/public'; +import { OnSourceChangeArgs } from '../../../connected_components/layer_panel/view'; + +interface Props { + filterByMapBounds: boolean; + indexPatternId: string; + onChange: (args: OnSourceChangeArgs) => void; + scalingType: SCALING_TYPES; + supportsClustering: boolean; + termFields: IFieldType[]; + topHitsSplitField?: string; + topHitsSize: number; +} + +interface State { + maxInnerResultWindow: number; + maxResultWindow: number; +} + +export class ScalingForm extends Component { + state = { + maxInnerResultWindow: DEFAULT_MAX_INNER_RESULT_WINDOW, + maxResultWindow: DEFAULT_MAX_RESULT_WINDOW, + }; + _isMounted = false; + + componentDidMount() { + this._isMounted = true; + this.loadIndexSettings(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async loadIndexSettings() { + try { + const indexPattern = await indexPatternService.get(this.props.indexPatternId); + const { maxInnerResultWindow, maxResultWindow } = await loadIndexSettings(indexPattern.title); + if (this._isMounted) { + this.setState({ maxInnerResultWindow, maxResultWindow }); + } + } catch (err) { + return; + } + } + + _onScalingTypeChange = (optionId: string): void => { + const layerType = + optionId === SCALING_TYPES.CLUSTERS ? LAYER_TYPE.BLENDED_VECTOR : LAYER_TYPE.VECTOR; + this.props.onChange({ propName: 'scalingType', value: optionId, newLayerType: layerType }); + }; + + _onFilterByMapBoundsChange = (event: EuiSwitchEvent) => { + this.props.onChange({ propName: 'filterByMapBounds', value: event.target.checked }); + }; + + _onTopHitsSplitFieldChange = (topHitsSplitField: string) => { + this.props.onChange({ propName: 'topHitsSplitField', value: topHitsSplitField }); + }; + + _onTopHitsSizeChange = (size: number) => { + this.props.onChange({ propName: 'topHitsSize', value: size }); + }; + + _renderTopHitsForm() { + let sizeSlider; + if (this.props.topHitsSplitField) { + sizeSlider = ( + + + + ); + } + + return ( + + + + + + {sizeSlider} + + ); + } + + render() { + const scalingOptions = [ + { + id: SCALING_TYPES.LIMIT, + label: i18n.translate('xpack.maps.source.esSearch.limitScalingLabel', { + defaultMessage: 'Limit results to {maxResultWindow}.', + values: { maxResultWindow: this.state.maxResultWindow }, + }), + }, + { + id: SCALING_TYPES.TOP_HITS, + label: i18n.translate('xpack.maps.source.esSearch.useTopHitsLabel', { + defaultMessage: 'Show top hits per entity.', + }), + }, + ]; + if (this.props.supportsClustering) { + scalingOptions.push({ + id: SCALING_TYPES.CLUSTERS, + label: i18n.translate('xpack.maps.source.esSearch.clusterScalingLabel', { + defaultMessage: 'Show clusters when results exceed {maxResultWindow}.', + values: { maxResultWindow: this.state.maxResultWindow }, + }), + }); + } + + let filterByBoundsSwitch; + if (this.props.scalingType !== SCALING_TYPES.CLUSTERS) { + filterByBoundsSwitch = ( + + + + ); + } + + let scalingForm = null; + if (this.props.scalingType === SCALING_TYPES.TOP_HITS) { + scalingForm = ( + + + {this._renderTopHitsForm()} + + ); + } + + return ( + + +
+ +
+
+ + + + + + + + {filterByBoundsSwitch} + + {scalingForm} +
+ ); + } +} diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js index 4d1e32087ab8c..9c92ec5801e49 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js @@ -6,34 +6,18 @@ import React, { Fragment, Component } from 'react'; import PropTypes from 'prop-types'; -import { - EuiFormRow, - EuiSwitch, - EuiSelect, - EuiTitle, - EuiPanel, - EuiSpacer, - EuiHorizontalRule, - EuiRadioGroup, -} from '@elastic/eui'; +import { EuiFormRow, EuiSelect, EuiTitle, EuiPanel, EuiSpacer } from '@elastic/eui'; import { SingleFieldSelect } from '../../../components/single_field_select'; import { TooltipSelector } from '../../../components/tooltip_selector'; import { getIndexPatternService } from '../../../kibana_services'; import { i18n } from '@kbn/i18n'; import { getTermsFields, getSourceFields } from '../../../index_pattern_util'; -import { ValidatedRange } from '../../../components/validated_range'; -import { - DEFAULT_MAX_INNER_RESULT_WINDOW, - DEFAULT_MAX_RESULT_WINDOW, - SORT_ORDER, - SCALING_TYPES, - LAYER_TYPE, -} from '../../../../common/constants'; +import { SORT_ORDER } from '../../../../common/constants'; import { ESDocField } from '../../fields/es_doc_field'; import { FormattedMessage } from '@kbn/i18n/react'; -import { loadIndexSettings } from './load_index_settings'; import { indexPatterns } from '../../../../../../../../src/plugins/data/public'; +import { ScalingForm } from './scaling_form'; export class UpdateSourceEditor extends Component { static propTypes = { @@ -52,33 +36,18 @@ export class UpdateSourceEditor extends Component { sourceFields: null, termFields: null, sortFields: null, - maxInnerResultWindow: DEFAULT_MAX_INNER_RESULT_WINDOW, - maxResultWindow: DEFAULT_MAX_RESULT_WINDOW, supportsClustering: false, }; componentDidMount() { this._isMounted = true; this.loadFields(); - this.loadIndexSettings(); } componentWillUnmount() { this._isMounted = false; } - async loadIndexSettings() { - try { - const indexPattern = await getIndexPatternService().get(this.props.indexPatternId); - const { maxInnerResultWindow, maxResultWindow } = await loadIndexSettings(indexPattern.title); - if (this._isMounted) { - this.setState({ maxInnerResultWindow, maxResultWindow }); - } - } catch (err) { - return; - } - } - async loadFields() { let indexPattern; try { @@ -133,85 +102,14 @@ export class UpdateSourceEditor extends Component { this.props.onChange({ propName: 'tooltipProperties', value: propertyNames }); }; - _onScalingTypeChange = optionId => { - const layerType = - optionId === SCALING_TYPES.CLUSTERS ? LAYER_TYPE.BLENDED_VECTOR : LAYER_TYPE.VECTOR; - this.props.onChange({ propName: 'scalingType', value: optionId, newLayerType: layerType }); - }; - - _onFilterByMapBoundsChange = event => { - this.props.onChange({ propName: 'filterByMapBounds', value: event.target.checked }); - }; - - onTopHitsSplitFieldChange = topHitsSplitField => { - this.props.onChange({ propName: 'topHitsSplitField', value: topHitsSplitField }); - }; - - onSortFieldChange = sortField => { + _onSortFieldChange = sortField => { this.props.onChange({ propName: 'sortField', value: sortField }); }; - onSortOrderChange = e => { + _onSortOrderChange = e => { this.props.onChange({ propName: 'sortOrder', value: e.target.value }); }; - onTopHitsSizeChange = size => { - this.props.onChange({ propName: 'topHitsSize', value: size }); - }; - - _renderTopHitsForm() { - let sizeSlider; - if (this.props.topHitsSplitField) { - sizeSlider = ( - - - - ); - } - - return ( - - - - - - {sizeSlider} - - ); - } - _renderTooltipsPanel() { return ( @@ -257,7 +155,7 @@ export class UpdateSourceEditor extends Component { defaultMessage: 'Select sort field', })} value={this.props.sortField} - onChange={this.onSortFieldChange} + onChange={this._onSortFieldChange} fields={this.state.sortFields} compressed /> @@ -286,7 +184,7 @@ export class UpdateSourceEditor extends Component { }, ]} value={this.props.sortOrder} - onChange={this.onSortOrderChange} + onChange={this._onSortOrderChange} compressed /> @@ -295,78 +193,18 @@ export class UpdateSourceEditor extends Component { } _renderScalingPanel() { - const scalingOptions = [ - { - id: SCALING_TYPES.LIMIT, - label: i18n.translate('xpack.maps.source.esSearch.limitScalingLabel', { - defaultMessage: 'Limit results to {maxResultWindow}.', - values: { maxResultWindow: this.state.maxResultWindow }, - }), - }, - { - id: SCALING_TYPES.TOP_HITS, - label: i18n.translate('xpack.maps.source.esSearch.useTopHitsLabel', { - defaultMessage: 'Show top hits per entity.', - }), - }, - ]; - if (this.state.supportsClustering) { - scalingOptions.push({ - id: SCALING_TYPES.CLUSTERS, - label: i18n.translate('xpack.maps.source.esSearch.clusterScalingLabel', { - defaultMessage: 'Show clusters when results exceed {maxResultWindow}.', - values: { maxResultWindow: this.state.maxResultWindow }, - }), - }); - } - - let filterByBoundsSwitch; - if (this.props.scalingType !== SCALING_TYPES.CLUSTERS) { - filterByBoundsSwitch = ( - - - - ); - } - - let scalingForm = null; - if (this.props.scalingType === SCALING_TYPES.TOP_HITS) { - scalingForm = ( - - - {this._renderTopHitsForm()} - - ); - } - return ( - -
- -
-
- - - - - - - - {filterByBoundsSwitch} - - {scalingForm} +
); } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js index e8a845c4b1669..65a91ce03994a 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js @@ -40,11 +40,3 @@ test('should enable sort order select when sort field provided', async () => { expect(component).toMatchSnapshot(); }); - -test('should render top hits form when scaling type is TOP_HITS', async () => { - const component = shallow( - - ); - - expect(component).toMatchSnapshot(); -}); diff --git a/x-pack/legacy/plugins/maps/server/routes.js b/x-pack/legacy/plugins/maps/server/routes.js index 7ca659148449f..6aacfdc41aeea 100644 --- a/x-pack/legacy/plugins/maps/server/routes.js +++ b/x-pack/legacy/plugins/maps/server/routes.js @@ -409,26 +409,6 @@ export function initRoutes(server, licenseUid) { }, }); - server.route({ - method: 'GET', - path: `${ROOT}/indexCount`, - handler: async (request, h) => { - const { server, query } = request; - - if (!query.index) { - return h.response().code(400); - } - - const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); - try { - const { count } = await callWithRequest(request, 'count', { index: query.index }); - return { count }; - } catch (error) { - return h.response().code(400); - } - }, - }); - server.route({ method: 'GET', path: `/${INDEX_SETTINGS_API_PATH}`, diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index fecf8db0e85de..12b03f0386304 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -43,13 +43,13 @@ export function createMapPath(id: string) { return `${MAP_BASE_URL}/${id}`; } -export const LAYER_TYPE = { - TILE: 'TILE', - VECTOR: 'VECTOR', - VECTOR_TILE: 'VECTOR_TILE', - HEATMAP: 'HEATMAP', - BLENDED_VECTOR: 'BLENDED_VECTOR', -}; +export enum LAYER_TYPE { + TILE = 'TILE', + VECTOR = 'VECTOR', + VECTOR_TILE = 'VECTOR_TILE', + HEATMAP = 'HEATMAP', + BLENDED_VECTOR = 'BLENDED_VECTOR', +} export enum SORT_ORDER { ASC = 'asc', diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ce78847a8e8b3..e8d93ba6d3200 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7170,9 +7170,6 @@ "xpack.maps.source.esGridDescription": "それぞれのグリッド付きセルのメトリックでグリッドにグループ分けされた地理空間データです。", "xpack.maps.source.esGridTitle": "グリッド集約", "xpack.maps.source.esSearch.convertToGeoJsonErrorMsg": "検索への応答を geoJson 機能コレクションに変換できません。エラー: {errorMsg}", - "xpack.maps.source.esSearch.disableFilterByMapBoundsExplainMsg": "インデックス「{indexPatternTitle}」はドキュメント数が少なく、ダイナミックフィルターが必要ありません。", - "xpack.maps.source.esSearch.disableFilterByMapBoundsTitle": "ダイナミックデータフィルターは無効です", - "xpack.maps.source.esSearch.disableFilterByMapBoundsTurnOnMsg": "ドキュメント数が増えると思われる場合はダイナミックフィルターをオンにしてください。", "xpack.maps.source.esSearch.extentFilterLabel": "マップの表示範囲でデータを動的にフィルタリング", "xpack.maps.source.esSearch.geofieldLabel": "地理空間フィールド", "xpack.maps.source.esSearch.geoFieldLabel": "地理空間フィールド", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d5d0a2f9e7aff..cfab424935c6d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7170,9 +7170,6 @@ "xpack.maps.source.esGridDescription": "地理空间数据在网格中进行分组,每个网格单元格都具有指标", "xpack.maps.source.esGridTitle": "网格聚合", "xpack.maps.source.esSearch.convertToGeoJsonErrorMsg": "无法将搜索响应转换成 geoJson 功能集合,错误:{errorMsg}", - "xpack.maps.source.esSearch.disableFilterByMapBoundsExplainMsg": "索引“{indexPatternTitle}”具有很少数量的文档,不需要动态筛选。", - "xpack.maps.source.esSearch.disableFilterByMapBoundsTitle": "动态数据筛选已禁用", - "xpack.maps.source.esSearch.disableFilterByMapBoundsTurnOnMsg": "如果预期文档数量会增加,请打开动态筛选。", "xpack.maps.source.esSearch.extentFilterLabel": "在可见地图区域中动态筛留数据", "xpack.maps.source.esSearch.geofieldLabel": "地理空间字段", "xpack.maps.source.esSearch.geoFieldLabel": "地理空间字段", From 72bc0eae3268b1dfb7c314e80438c7a88f61352e Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Mon, 23 Mar 2020 19:02:28 -0400 Subject: [PATCH 048/179] [Alerting] allow email action to not require auth (#60839) resolves https://github.com/elastic/kibana/issues/57143 Currently, the built-in email action requires user/password properties to be set in it's secrets parameters. This PR changes that requirement, so they are no longer required. --- .../server/builtin_action_types/email.test.ts | 14 ++-- .../server/builtin_action_types/email.ts | 25 +++--- .../builtin_action_types/email.test.tsx | 81 +++++++++++++++++++ .../components/builtin_action_types/email.tsx | 44 +++++----- .../components/builtin_action_types/types.ts | 4 +- .../actions/builtin_action_types/email.ts | 56 +++++++++++++ 6 files changed, 181 insertions(+), 43 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 0bd3992de30e6..469df4fd86e2c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -184,12 +184,14 @@ describe('secrets validation', () => { expect(validateSecrets(actionType, secrets)).toEqual(secrets); }); - test('secrets validation fails when secrets is not valid', () => { - expect(() => { - validateSecrets(actionType, {}); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type secrets: [user]: expected value of type [string] but got [undefined]"` - ); + test('secrets validation succeeds when secrets props are null/undefined', () => { + const secrets: Record = { + user: null, + password: null, + }; + expect(validateSecrets(actionType, {})).toEqual(secrets); + expect(validateSecrets(actionType, { user: null })).toEqual(secrets); + expect(validateSecrets(actionType, { password: null })).toEqual(secrets); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.ts b/x-pack/plugins/actions/server/builtin_action_types/email.ts index 16e0168a7deb9..7992920fdfcb4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.ts @@ -10,7 +10,6 @@ import { schema, TypeOf } from '@kbn/config-schema'; import nodemailerGetService from 'nodemailer/lib/well-known'; import { sendEmail, JSON_TRANSPORT_SERVICE } from './lib/send_email'; -import { nullableType } from './lib/nullable'; import { portSchema } from './lib/schemas'; import { Logger } from '../../../../../src/core/server'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; @@ -20,10 +19,10 @@ import { ActionsConfigurationUtilities } from '../actions_config'; export type ActionTypeConfigType = TypeOf; const ConfigSchemaProps = { - service: nullableType(schema.string()), - host: nullableType(schema.string()), - port: nullableType(portSchema()), - secure: nullableType(schema.boolean()), + service: schema.nullable(schema.string()), + host: schema.nullable(schema.string()), + port: schema.nullable(portSchema()), + secure: schema.nullable(schema.boolean()), from: schema.string(), }; @@ -75,8 +74,8 @@ function validateConfig( export type ActionTypeSecretsType = TypeOf; const SecretsSchema = schema.object({ - user: schema.string(), - password: schema.string(), + user: schema.nullable(schema.string()), + password: schema.nullable(schema.string()), }); // params definition @@ -144,10 +143,14 @@ async function executor( const secrets = execOptions.secrets as ActionTypeSecretsType; const params = execOptions.params as ActionParamsType; - const transport: any = { - user: secrets.user, - password: secrets.password, - }; + const transport: any = {}; + + if (secrets.user != null) { + transport.user = secrets.user; + } + if (secrets.password != null) { + transport.password = secrets.password; + } if (config.service !== null) { transport.service = config.service; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx index 49a611167cf16..a7d479f922ed1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx @@ -58,6 +58,33 @@ describe('connector validation', () => { }); }); + test('connector validation succeeds when connector config is valid with empty user/password', () => { + const actionConnector = { + secrets: { + user: null, + password: null, + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + port: 2323, + host: 'localhost', + test: 'test', + }, + } as EmailActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + from: [], + port: [], + host: [], + user: [], + password: [], + }, + }); + }); test('connector validation fails when connector config is not valid', () => { const actionConnector = { secrets: { @@ -82,6 +109,60 @@ describe('connector validation', () => { }, }); }); + test('connector validation fails when user specified but not password', () => { + const actionConnector = { + secrets: { + user: 'user', + password: null, + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + port: 2323, + host: 'localhost', + test: 'test', + }, + } as EmailActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + from: [], + port: [], + host: [], + user: [], + password: ['Password is required when username is used.'], + }, + }); + }); + test('connector validation fails when password specified but not user', () => { + const actionConnector = { + secrets: { + user: null, + password: 'password', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + port: 2323, + host: 'localhost', + test: 'test', + }, + } as EmailActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + from: [], + port: [], + host: [], + user: ['Username is required when password is used.'], + password: [], + }, + }); + }); }); describe('action params validation', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx index 6c994051ec980..f17180ee74e56 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx @@ -97,22 +97,22 @@ export function getActionType(): ActionTypeModel { ) ); } - if (!action.secrets.user) { - errors.user.push( + if (action.secrets.user && !action.secrets.password) { + errors.password.push( i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredUserText', + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText', { - defaultMessage: 'Username is required.', + defaultMessage: 'Password is required when username is used.', } ) ); } - if (!action.secrets.password) { - errors.password.push( + if (!action.secrets.user && action.secrets.password) { + errors.user.push( i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText', + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredUserText', { - defaultMessage: 'Password is required.', + defaultMessage: 'Username is required when password is used.', } ) ); @@ -303,7 +303,7 @@ const EmailActionConnectorFields: React.FunctionComponent 0 && user !== undefined} + isInvalid={errors.user.length > 0} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.userTextFieldLabel', { @@ -313,17 +313,12 @@ const EmailActionConnectorFields: React.FunctionComponent 0 && user !== undefined} + isInvalid={errors.user.length > 0} name="user" value={user || ''} data-test-subj="emailUserInput" onChange={e => { - editActionSecrets('user', e.target.value); - }} - onBlur={() => { - if (!user) { - editActionSecrets('user', ''); - } + editActionSecrets('user', nullableString(e.target.value)); }} /> @@ -333,7 +328,7 @@ const EmailActionConnectorFields: React.FunctionComponent 0 && password !== undefined} + isInvalid={errors.password.length > 0} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.passwordFieldLabel', { @@ -343,17 +338,12 @@ const EmailActionConnectorFields: React.FunctionComponent 0 && password !== undefined} + isInvalid={errors.password.length > 0} name="password" value={password || ''} data-test-subj="emailPasswordInput" onChange={e => { - editActionSecrets('password', e.target.value); - }} - onBlur={() => { - if (!password) { - editActionSecrets('password', ''); - } + editActionSecrets('password', nullableString(e.target.value)); }} /> @@ -624,3 +614,9 @@ const EmailParamsFields: React.FunctionComponent ); }; + +// if the string == null or is empty, return null, else return string +function nullableString(str: string | null | undefined) { + if (str == null || str.trim() === '') return null; + return str; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts index c0ddd6791e90e..2e0576d933f90 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts @@ -72,8 +72,8 @@ interface EmailConfig { } interface EmailSecrets { - user: string; - password: string; + user: string | null; + password: string | null; } export interface EmailActionConnector extends ActionConnector { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts index de856492e12fc..e228f6c1f81c6 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts @@ -227,5 +227,61 @@ export default function emailTest({ getService }: FtrProviderContext) { .expect(200); expect(typeof createdAction.id).to.be('string'); }); + + it('should handle an email action with no auth', async () => { + const { body: createdAction } = await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An email action with no auth', + actionTypeId: '.email', + config: { + service: '__json', + from: 'jim@example.com', + }, + }) + .expect(200); + + await supertest + .post(`/api/action/${createdAction.id}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + to: ['kibana-action-test@elastic.co'], + subject: 'email-subject', + message: 'email-message', + }, + }) + .expect(200) + .then((resp: any) => { + expect(resp.body.data.message.messageId).to.be.a('string'); + expect(resp.body.data.messageId).to.be.a('string'); + + delete resp.body.data.message.messageId; + delete resp.body.data.messageId; + + expect(resp.body.data).to.eql({ + envelope: { + from: 'jim@example.com', + to: ['kibana-action-test@elastic.co'], + }, + message: { + from: { address: 'jim@example.com', name: '' }, + to: [ + { + address: 'kibana-action-test@elastic.co', + name: '', + }, + ], + cc: null, + bcc: null, + subject: 'email-subject', + html: '

email-message

\n', + text: 'email-message', + headers: {}, + }, + }); + }); + }); }); } From 5755b2ac522483bd71ad0e1b31459338ff69cf93 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Mon, 23 Mar 2020 16:02:44 -0700 Subject: [PATCH 049/179] [Reporting/New Platform Migration] Use a new config service on server-side (#55882) * [Reporting/New Platform Migration] Use a new config service on server-side * unit test for createConfig * use promise.all and remove outdated comment * design feedback to avoid handling the entire config getter Co-authored-by: Elastic Machine --- .../__snapshots__/index.test.js.snap | 383 ------------------ x-pack/legacy/plugins/reporting/config.ts | 182 --------- .../execute_job/decrypt_job_headers.test.ts | 22 +- .../common/execute_job/decrypt_job_headers.ts | 8 +- .../get_conditional_headers.test.ts | 173 ++------ .../execute_job/get_conditional_headers.ts | 20 +- .../execute_job/get_custom_logo.test.ts | 14 +- .../common/execute_job/get_custom_logo.ts | 11 +- .../common/execute_job/get_full_urls.test.ts | 80 ++-- .../common/execute_job/get_full_urls.ts | 22 +- .../common/layouts/create_layout.ts | 7 +- .../common/layouts/print_layout.ts | 9 +- .../lib/screenshots/get_number_of_items.ts | 7 +- .../common/lib/screenshots/observable.test.ts | 19 +- .../common/lib/screenshots/observable.ts | 18 +- .../common/lib/screenshots/open_url.ts | 11 +- .../common/lib/screenshots/types.ts | 2 +- .../common/lib/screenshots/wait_for_render.ts | 4 +- .../screenshots/wait_for_visualizations.ts | 7 +- .../export_types/csv/server/create_job.ts | 6 +- .../csv/server/execute_job.test.js | 344 +++------------- .../export_types/csv/server/execute_job.ts | 30 +- .../csv/server/lib/hit_iterator.test.ts | 3 +- .../csv/server/lib/hit_iterator.ts | 5 +- .../reporting/export_types/csv/types.d.ts | 5 +- .../server/create_job/create_job.ts | 19 +- .../server/execute_job.ts | 23 +- .../server/lib/generate_csv.ts | 16 +- .../server/lib/generate_csv_search.ts | 20 +- .../csv_from_savedobject/types.d.ts | 5 +- .../png/server/create_job/index.ts | 6 +- .../png/server/execute_job/index.test.js | 94 ++--- .../png/server/execute_job/index.ts | 23 +- .../png/server/lib/generate_png.ts | 7 +- .../printable_pdf/server/create_job/index.ts | 6 +- .../server/execute_job/index.test.js | 80 ++-- .../printable_pdf/server/execute_job/index.ts | 25 +- .../printable_pdf/server/lib/generate_pdf.ts | 9 +- .../export_types/printable_pdf/types.d.ts | 2 +- x-pack/legacy/plugins/reporting/index.test.js | 34 -- x-pack/legacy/plugins/reporting/index.ts | 16 +- .../plugins/reporting/log_configuration.ts | 23 +- .../browsers/chromium/driver_factory/args.ts | 7 +- .../browsers/chromium/driver_factory/index.ts | 19 +- .../server/browsers/chromium/index.ts | 5 +- .../browsers/create_browser_driver_factory.ts | 22 +- .../browsers/download/ensure_downloaded.ts | 13 +- .../server/browsers/network_policy.ts | 9 +- .../reporting/server/browsers/types.d.ts | 2 - .../plugins/reporting/server/config/config.js | 21 - .../legacy/plugins/reporting/server/core.ts | 72 +++- .../legacy/plugins/reporting/server/index.ts | 2 +- .../legacy/plugins/reporting/server/legacy.ts | 73 +++- .../reporting/server/lib/create_queue.ts | 20 +- .../server/lib/create_worker.test.ts | 39 +- .../reporting/server/lib/create_worker.ts | 24 +- .../plugins/reporting/server/lib/crypto.ts | 7 +- .../reporting/server/lib/enqueue_job.ts | 31 +- .../lib/esqueue/helpers/index_timestamp.js | 1 + .../plugins/reporting/server/lib/get_user.ts | 4 +- .../plugins/reporting/server/lib/index.ts | 9 +- .../reporting/server/lib/jobs_query.ts | 10 +- .../reporting/server/lib/once_per_server.ts | 43 -- .../__tests__/validate_encryption_key.js | 34 -- .../__tests__/validate_server_host.ts | 30 -- .../reporting/server/lib/validate/index.ts | 13 +- .../server/lib/validate/validate_browser.ts | 4 +- .../lib/validate/validate_encryption_key.ts | 31 -- .../validate_max_content_length.test.js | 16 +- .../validate/validate_max_content_length.ts | 14 +- .../lib/validate/validate_server_host.ts | 27 -- .../legacy/plugins/reporting/server/plugin.ts | 24 +- .../server/routes/generate_from_jobparams.ts | 5 +- .../routes/generate_from_savedobject.ts | 5 +- .../generate_from_savedobject_immediate.ts | 18 +- .../server/routes/generation.test.ts | 11 +- .../reporting/server/routes/generation.ts | 15 +- .../plugins/reporting/server/routes/index.ts | 7 +- .../reporting/server/routes/jobs.test.js | 46 ++- .../plugins/reporting/server/routes/jobs.ts | 15 +- .../lib/authorized_user_pre_routing.test.js | 131 +++--- .../routes/lib/authorized_user_pre_routing.ts | 16 +- .../server/routes/lib/get_document_payload.ts | 31 +- .../server/routes/lib/job_response_handler.ts | 15 +- .../lib/reporting_feature_pre_routing.ts | 8 +- .../routes/lib/route_config_factories.ts | 28 +- .../plugins/reporting/server/types.d.ts | 11 +- .../server/usage/get_reporting_usage.ts | 28 +- .../usage/reporting_usage_collector.test.js | 152 +++---- .../server/usage/reporting_usage_collector.ts | 23 +- .../create_mock_browserdriverfactory.ts | 45 +- .../create_mock_layoutinstance.ts | 8 +- .../create_mock_reportingplugin.ts | 22 +- .../test_helpers/create_mock_server.ts | 34 +- x-pack/legacy/plugins/reporting/types.d.ts | 62 +-- x-pack/plugins/reporting/config.ts | 10 - x-pack/plugins/reporting/kibana.json | 6 +- .../reporting/server/config/index.test.ts | 122 ++++++ .../plugins/reporting/server/config/index.ts | 85 ++++ .../reporting/server/config/schema.test.ts | 103 +++++ .../plugins/reporting/server/config/schema.ts | 174 ++++++++ x-pack/plugins/reporting/server/index.ts | 14 + x-pack/plugins/reporting/server/plugin.ts | 38 ++ 103 files changed, 1522 insertions(+), 2192 deletions(-) delete mode 100644 x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap delete mode 100644 x-pack/legacy/plugins/reporting/config.ts delete mode 100644 x-pack/legacy/plugins/reporting/index.test.js delete mode 100644 x-pack/legacy/plugins/reporting/server/config/config.js delete mode 100644 x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts delete mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js delete mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts delete mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts delete mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts delete mode 100644 x-pack/plugins/reporting/config.ts create mode 100644 x-pack/plugins/reporting/server/config/index.test.ts create mode 100644 x-pack/plugins/reporting/server/config/index.ts create mode 100644 x-pack/plugins/reporting/server/config/schema.test.ts create mode 100644 x-pack/plugins/reporting/server/config/schema.ts create mode 100644 x-pack/plugins/reporting/server/index.ts create mode 100644 x-pack/plugins/reporting/server/plugin.ts diff --git a/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap b/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap deleted file mode 100644 index 757677f1d4f82..0000000000000 --- a/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap +++ /dev/null @@ -1,383 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`config schema with context {"dev":false,"dist":false} produces correct config 1`] = ` -Object { - "capture": Object { - "browser": Object { - "autoDownload": true, - "chromium": Object { - "disableSandbox": "", - "maxScreenshotDimension": 1950, - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, - "concurrency": 4, - "loadDelay": 3000, - "maxAttempts": 1, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "protocol": "http:", - }, - Object { - "allow": true, - "protocol": "https:", - }, - Object { - "allow": true, - "protocol": "ws:", - }, - Object { - "allow": true, - "protocol": "wss:", - }, - Object { - "allow": true, - "protocol": "data:", - }, - Object { - "allow": false, - }, - ], - }, - "settleTime": 1000, - "timeout": 20000, - "timeouts": Object { - "openUrl": 30000, - "renderComplete": 30000, - "waitForElements": 30000, - }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, - "zoom": 2, - }, - "csv": Object { - "checkForFormulas": true, - "enablePanelActionDownload": true, - "maxSizeBytes": 10485760, - "scroll": Object { - "duration": "30s", - "size": 500, - }, - }, - "enabled": true, - "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "index": ".reporting", - "kibanaServer": Object {}, - "poll": Object { - "jobCompletionNotifier": Object { - "interval": 10000, - "intervalErrorMultiplier": 5, - }, - "jobsRefresh": Object { - "interval": 5000, - "intervalErrorMultiplier": 5, - }, - }, - "queue": Object { - "indexInterval": "week", - "pollEnabled": true, - "pollInterval": 3000, - "pollIntervalErrorMultiplier": 10, - "timeout": 120000, - }, - "roles": Object { - "allow": Array [ - "reporting_user", - ], - }, -} -`; - -exports[`config schema with context {"dev":false,"dist":true} produces correct config 1`] = ` -Object { - "capture": Object { - "browser": Object { - "autoDownload": false, - "chromium": Object { - "disableSandbox": "", - "maxScreenshotDimension": 1950, - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, - "concurrency": 4, - "loadDelay": 3000, - "maxAttempts": 3, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "protocol": "http:", - }, - Object { - "allow": true, - "protocol": "https:", - }, - Object { - "allow": true, - "protocol": "ws:", - }, - Object { - "allow": true, - "protocol": "wss:", - }, - Object { - "allow": true, - "protocol": "data:", - }, - Object { - "allow": false, - }, - ], - }, - "settleTime": 1000, - "timeout": 20000, - "timeouts": Object { - "openUrl": 30000, - "renderComplete": 30000, - "waitForElements": 30000, - }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, - "zoom": 2, - }, - "csv": Object { - "checkForFormulas": true, - "enablePanelActionDownload": true, - "maxSizeBytes": 10485760, - "scroll": Object { - "duration": "30s", - "size": 500, - }, - }, - "enabled": true, - "index": ".reporting", - "kibanaServer": Object {}, - "poll": Object { - "jobCompletionNotifier": Object { - "interval": 10000, - "intervalErrorMultiplier": 5, - }, - "jobsRefresh": Object { - "interval": 5000, - "intervalErrorMultiplier": 5, - }, - }, - "queue": Object { - "indexInterval": "week", - "pollEnabled": true, - "pollInterval": 3000, - "pollIntervalErrorMultiplier": 10, - "timeout": 120000, - }, - "roles": Object { - "allow": Array [ - "reporting_user", - ], - }, -} -`; - -exports[`config schema with context {"dev":true,"dist":false} produces correct config 1`] = ` -Object { - "capture": Object { - "browser": Object { - "autoDownload": true, - "chromium": Object { - "disableSandbox": "", - "maxScreenshotDimension": 1950, - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, - "concurrency": 4, - "loadDelay": 3000, - "maxAttempts": 1, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "protocol": "http:", - }, - Object { - "allow": true, - "protocol": "https:", - }, - Object { - "allow": true, - "protocol": "ws:", - }, - Object { - "allow": true, - "protocol": "wss:", - }, - Object { - "allow": true, - "protocol": "data:", - }, - Object { - "allow": false, - }, - ], - }, - "settleTime": 1000, - "timeout": 20000, - "timeouts": Object { - "openUrl": 30000, - "renderComplete": 30000, - "waitForElements": 30000, - }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, - "zoom": 2, - }, - "csv": Object { - "checkForFormulas": true, - "enablePanelActionDownload": true, - "maxSizeBytes": 10485760, - "scroll": Object { - "duration": "30s", - "size": 500, - }, - }, - "enabled": true, - "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "index": ".reporting", - "kibanaServer": Object {}, - "poll": Object { - "jobCompletionNotifier": Object { - "interval": 10000, - "intervalErrorMultiplier": 5, - }, - "jobsRefresh": Object { - "interval": 5000, - "intervalErrorMultiplier": 5, - }, - }, - "queue": Object { - "indexInterval": "week", - "pollEnabled": true, - "pollInterval": 3000, - "pollIntervalErrorMultiplier": 10, - "timeout": 120000, - }, - "roles": Object { - "allow": Array [ - "reporting_user", - ], - }, -} -`; - -exports[`config schema with context {"dev":true,"dist":true} produces correct config 1`] = ` -Object { - "capture": Object { - "browser": Object { - "autoDownload": false, - "chromium": Object { - "disableSandbox": "", - "maxScreenshotDimension": 1950, - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, - "concurrency": 4, - "loadDelay": 3000, - "maxAttempts": 3, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "protocol": "http:", - }, - Object { - "allow": true, - "protocol": "https:", - }, - Object { - "allow": true, - "protocol": "ws:", - }, - Object { - "allow": true, - "protocol": "wss:", - }, - Object { - "allow": true, - "protocol": "data:", - }, - Object { - "allow": false, - }, - ], - }, - "settleTime": 1000, - "timeout": 20000, - "timeouts": Object { - "openUrl": 30000, - "renderComplete": 30000, - "waitForElements": 30000, - }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, - "zoom": 2, - }, - "csv": Object { - "checkForFormulas": true, - "enablePanelActionDownload": true, - "maxSizeBytes": 10485760, - "scroll": Object { - "duration": "30s", - "size": 500, - }, - }, - "enabled": true, - "index": ".reporting", - "kibanaServer": Object {}, - "poll": Object { - "jobCompletionNotifier": Object { - "interval": 10000, - "intervalErrorMultiplier": 5, - }, - "jobsRefresh": Object { - "interval": 5000, - "intervalErrorMultiplier": 5, - }, - }, - "queue": Object { - "indexInterval": "week", - "pollEnabled": true, - "pollInterval": 3000, - "pollIntervalErrorMultiplier": 10, - "timeout": 120000, - }, - "roles": Object { - "allow": Array [ - "reporting_user", - ], - }, -} -`; diff --git a/x-pack/legacy/plugins/reporting/config.ts b/x-pack/legacy/plugins/reporting/config.ts deleted file mode 100644 index 211fa70301bbf..0000000000000 --- a/x-pack/legacy/plugins/reporting/config.ts +++ /dev/null @@ -1,182 +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 { BROWSER_TYPE } from './common/constants'; -// @ts-ignore untyped module -import { config as appConfig } from './server/config/config'; -import { getDefaultChromiumSandboxDisabled } from './server/browsers'; - -export async function config(Joi: any) { - return Joi.object({ - enabled: Joi.boolean().default(true), - kibanaServer: Joi.object({ - protocol: Joi.string().valid(['http', 'https']), - hostname: Joi.string().invalid('0'), - port: Joi.number().integer(), - }).default(), - queue: Joi.object({ - indexInterval: Joi.string().default('week'), - pollEnabled: Joi.boolean().default(true), - pollInterval: Joi.number() - .integer() - .default(3000), - pollIntervalErrorMultiplier: Joi.number() - .integer() - .default(10), - timeout: Joi.number() - .integer() - .default(120000), - }).default(), - capture: Joi.object({ - timeouts: Joi.object({ - openUrl: Joi.number() - .integer() - .default(30000), - waitForElements: Joi.number() - .integer() - .default(30000), - renderComplete: Joi.number() - .integer() - .default(30000), - }).default(), - networkPolicy: Joi.object({ - enabled: Joi.boolean().default(true), - rules: Joi.array() - .items( - Joi.object({ - allow: Joi.boolean().required(), - protocol: Joi.string(), - host: Joi.string(), - }) - ) - .default([ - { allow: true, protocol: 'http:' }, - { allow: true, protocol: 'https:' }, - { allow: true, protocol: 'ws:' }, - { allow: true, protocol: 'wss:' }, - { allow: true, protocol: 'data:' }, - { allow: false }, // Default action is to deny! - ]), - }).default(), - zoom: Joi.number() - .integer() - .default(2), - viewport: Joi.object({ - width: Joi.number() - .integer() - .default(1950), - height: Joi.number() - .integer() - .default(1200), - }).default(), - timeout: Joi.number() - .integer() - .default(20000), // deprecated - loadDelay: Joi.number() - .integer() - .default(3000), - settleTime: Joi.number() - .integer() - .default(1000), // deprecated - concurrency: Joi.number() - .integer() - .default(appConfig.concurrency), // deprecated - browser: Joi.object({ - type: Joi.any() - .valid(BROWSER_TYPE) - .default(BROWSER_TYPE), - autoDownload: Joi.boolean().when('$dist', { - is: true, - then: Joi.default(false), - otherwise: Joi.default(true), - }), - chromium: Joi.object({ - inspect: Joi.boolean() - .when('$dev', { - is: false, - then: Joi.valid(false), - else: Joi.default(false), - }) - .default(), - disableSandbox: Joi.boolean().default(await getDefaultChromiumSandboxDisabled()), - proxy: Joi.object({ - enabled: Joi.boolean().default(false), - server: Joi.string() - .uri({ scheme: ['http', 'https'] }) - .when('enabled', { - is: Joi.valid(false), - then: Joi.valid(null), - else: Joi.required(), - }), - bypass: Joi.array() - .items(Joi.string().regex(/^[^\s]+$/)) - .when('enabled', { - is: Joi.valid(false), - then: Joi.valid(null), - else: Joi.default([]), - }), - }).default(), - maxScreenshotDimension: Joi.number() - .integer() - .default(1950), - }).default(), - }).default(), - maxAttempts: Joi.number() - .integer() - .greater(0) - .when('$dist', { - is: true, - then: Joi.default(3), - otherwise: Joi.default(1), - }) - .default(), - }).default(), - csv: Joi.object({ - checkForFormulas: Joi.boolean().default(true), - enablePanelActionDownload: Joi.boolean().default(true), - maxSizeBytes: Joi.number() - .integer() - .default(1024 * 1024 * 10), // bytes in a kB * kB in a mB * 10 - scroll: Joi.object({ - duration: Joi.string() - .regex(/^[0-9]+(d|h|m|s|ms|micros|nanos)$/, { name: 'DurationString' }) - .default('30s'), - size: Joi.number() - .integer() - .default(500), - }).default(), - }).default(), - encryptionKey: Joi.when(Joi.ref('$dist'), { - is: true, - then: Joi.string(), - otherwise: Joi.string().default('a'.repeat(32)), - }), - roles: Joi.object({ - allow: Joi.array() - .items(Joi.string()) - .default(['reporting_user']), - }).default(), - index: Joi.string().default('.reporting'), - poll: Joi.object({ - jobCompletionNotifier: Joi.object({ - interval: Joi.number() - .integer() - .default(10000), - intervalErrorMultiplier: Joi.number() - .integer() - .default(5), - }).default(), - jobsRefresh: Joi.object({ - interval: Joi.number() - .integer() - .default(5000), - intervalErrorMultiplier: Joi.number() - .integer() - .default(5), - }).default(), - }).default(), - }).default(); -} diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts index 468caf93ec5dd..9085fb3cbc876 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts @@ -5,33 +5,27 @@ */ import { cryptoFactory } from '../../../server/lib/crypto'; -import { createMockServer } from '../../../test_helpers'; import { Logger } from '../../../types'; import { decryptJobHeaders } from './decrypt_job_headers'; -let mockServer: any; -beforeEach(() => { - mockServer = createMockServer(''); -}); - -const encryptHeaders = async (headers: Record) => { - const crypto = cryptoFactory(mockServer); +const encryptHeaders = async (encryptionKey: string, headers: Record) => { + const crypto = cryptoFactory(encryptionKey); return await crypto.encrypt(headers); }; describe('headers', () => { test(`fails if it can't decrypt headers`, async () => { - await expect( + const getDecryptedHeaders = () => decryptJobHeaders({ + encryptionKey: 'abcsecretsauce', job: { headers: 'Q53+9A+zf+Xe+ceR/uB/aR/Sw/8e+M+qR+WiG+8z+EY+mo+HiU/zQL+Xn', }, logger: ({ error: jest.fn(), } as unknown) as Logger, - server: mockServer, - }) - ).rejects.toMatchInlineSnapshot( + }); + await expect(getDecryptedHeaders()).rejects.toMatchInlineSnapshot( `[Error: Failed to decrypt report job data. Please ensure that xpack.reporting.encryptionKey is set and re-generate this report. Error: Invalid IV length]` ); }); @@ -42,15 +36,15 @@ describe('headers', () => { baz: 'quix', }; - const encryptedHeaders = await encryptHeaders(headers); + const encryptedHeaders = await encryptHeaders('abcsecretsauce', headers); const decryptedHeaders = await decryptJobHeaders({ + encryptionKey: 'abcsecretsauce', job: { title: 'cool-job-bro', type: 'csv', headers: encryptedHeaders, }, logger: {} as Logger, - server: mockServer, }); expect(decryptedHeaders).toEqual(headers); }); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts index 436b2c2dab1ad..6f415d7ee5ea9 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { cryptoFactory } from '../../../server/lib/crypto'; -import { CryptoFactory, ServerFacade, Logger } from '../../../types'; +import { CryptoFactory, Logger } from '../../../types'; interface HasEncryptedHeaders { headers?: string; @@ -17,15 +17,15 @@ export const decryptJobHeaders = async < JobParamsType, JobDocPayloadType extends HasEncryptedHeaders >({ - server, + encryptionKey, job, logger, }: { - server: ServerFacade; + encryptionKey?: string; job: JobDocPayloadType; logger: Logger; }): Promise> => { - const crypto: CryptoFactory = cryptoFactory(server); + const crypto: CryptoFactory = cryptoFactory(encryptionKey); try { const decryptedHeaders: Record = await crypto.decrypt(job.headers); return decryptedHeaders; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts index eedb742ad7597..09527621fa49f 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts @@ -4,27 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createMockReportingCore, createMockServer } from '../../../test_helpers'; -import { ReportingCore } from '../../../server'; +import sinon from 'sinon'; +import { createMockReportingCore } from '../../../test_helpers'; +import { ReportingConfig, ReportingCore } from '../../../server/types'; import { JobDocPayload } from '../../../types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; import { getConditionalHeaders, getCustomLogo } from './index'; +let mockConfig: ReportingConfig; let mockReportingPlugin: ReportingCore; -let mockServer: any; + +const getMockConfig = (mockConfigGet: sinon.SinonStub) => ({ + get: mockConfigGet, + kbnConfig: { get: mockConfigGet }, +}); + beforeEach(async () => { mockReportingPlugin = await createMockReportingCore(); - mockServer = createMockServer(''); + + const mockConfigGet = sinon + .stub() + .withArgs('kibanaServer', 'hostname') + .returns('custom-hostname'); + mockConfig = getMockConfig(mockConfigGet); }); describe('conditions', () => { test(`uses hostname from reporting config if set`, async () => { - const settings: any = { - 'xpack.reporting.kibanaServer.hostname': 'custom-hostname', - }; - - mockServer = createMockServer({ settings }); - const permittedHeaders = { foo: 'bar', baz: 'quix', @@ -33,121 +39,20 @@ describe('conditions', () => { const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: permittedHeaders, - server: mockServer, + config: mockConfig, }); expect(conditionalHeaders.conditions.hostname).toEqual( - mockServer.config().get('xpack.reporting.kibanaServer.hostname') + mockConfig.get('kibanaServer', 'hostname') ); - }); - - test(`uses hostname from server.config if reporting config not set`, async () => { - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - - expect(conditionalHeaders.conditions.hostname).toEqual(mockServer.config().get('server.host')); - }); - - test(`uses port from reporting config if set`, async () => { - const settings = { - 'xpack.reporting.kibanaServer.port': 443, - }; - - mockServer = createMockServer({ settings }); - - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - - expect(conditionalHeaders.conditions.port).toEqual( - mockServer.config().get('xpack.reporting.kibanaServer.port') + expect(conditionalHeaders.conditions.port).toEqual(mockConfig.get('kibanaServer', 'port')); + expect(conditionalHeaders.conditions.protocol).toEqual( + mockConfig.get('kibanaServer', 'protocol') ); - }); - - test(`uses port from server if reporting config not set`, async () => { - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - - expect(conditionalHeaders.conditions.port).toEqual(mockServer.config().get('server.port')); - }); - - test(`uses basePath from server config`, async () => { - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - expect(conditionalHeaders.conditions.basePath).toEqual( - mockServer.config().get('server.basePath') + mockConfig.kbnConfig.get('server', 'basePath') ); }); - - test(`uses protocol from reporting config if set`, async () => { - const settings = { - 'xpack.reporting.kibanaServer.protocol': 'https', - }; - - mockServer = createMockServer({ settings }); - - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - - expect(conditionalHeaders.conditions.protocol).toEqual( - mockServer.config().get('xpack.reporting.kibanaServer.protocol') - ); - }); - - test(`uses protocol from server.info`, async () => { - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - - expect(conditionalHeaders.conditions.protocol).toEqual(mockServer.info.protocol); - }); }); test('uses basePath from job when creating saved object service', async () => { @@ -161,14 +66,14 @@ test('uses basePath from job when creating saved object service', async () => { const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: permittedHeaders, - server: mockServer, + config: mockConfig, }); const jobBasePath = '/sbp/s/marketing'; await getCustomLogo({ reporting: mockReportingPlugin, job: { basePath: jobBasePath } as JobDocPayloadPDF, conditionalHeaders, - server: mockServer, + config: mockConfig, }); const getBasePath = mockGetSavedObjectsClient.mock.calls[0][0].getBasePath; @@ -179,6 +84,11 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav const mockGetSavedObjectsClient = jest.fn(); mockReportingPlugin.getSavedObjectsClient = mockGetSavedObjectsClient; + const mockConfigGet = sinon.stub(); + mockConfigGet.withArgs('kibanaServer', 'hostname').returns('localhost'); + mockConfigGet.withArgs('server', 'basePath').returns('/sbp'); + mockConfig = getMockConfig(mockConfigGet); + const permittedHeaders = { foo: 'bar', baz: 'quix', @@ -186,14 +96,14 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: permittedHeaders, - server: mockServer, + config: mockConfig, }); await getCustomLogo({ reporting: mockReportingPlugin, job: {} as JobDocPayloadPDF, conditionalHeaders, - server: mockServer, + config: mockConfig, }); const getBasePath = mockGetSavedObjectsClient.mock.calls[0][0].getBasePath; @@ -225,19 +135,26 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav describe('config formatting', () => { test(`lowercases server.host`, async () => { - mockServer = createMockServer({ settings: { 'server.host': 'COOL-HOSTNAME' } }); + const mockConfigGet = sinon + .stub() + .withArgs('server', 'host') + .returns('COOL-HOSTNAME'); + mockConfig = getMockConfig(mockConfigGet); + const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: {}, - server: mockServer, + config: mockConfig, }); expect(conditionalHeaders.conditions.hostname).toEqual('cool-hostname'); }); - test(`lowercases xpack.reporting.kibanaServer.hostname`, async () => { - mockServer = createMockServer({ - settings: { 'xpack.reporting.kibanaServer.hostname': 'GREAT-HOSTNAME' }, - }); + test(`lowercases kibanaServer.hostname`, async () => { + const mockConfigGet = sinon + .stub() + .withArgs('kibanaServer', 'hostname') + .returns('GREAT-HOSTNAME'); + mockConfig = getMockConfig(mockConfigGet); const conditionalHeaders = await getConditionalHeaders({ job: { title: 'cool-job-bro', @@ -249,7 +166,7 @@ describe('config formatting', () => { }, }, filteredHeaders: {}, - server: mockServer, + config: mockConfig, }); expect(conditionalHeaders.conditions.hostname).toEqual('great-hostname'); }); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts index 975060a8052f0..bd7999d697ca9 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts @@ -3,29 +3,31 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { ConditionalHeaders, ServerFacade } from '../../../types'; + +import { ReportingConfig } from '../../../server/types'; +import { ConditionalHeaders } from '../../../types'; export const getConditionalHeaders = ({ - server, + config, job, filteredHeaders, }: { - server: ServerFacade; + config: ReportingConfig; job: JobDocPayloadType; filteredHeaders: Record; }) => { - const config = server.config(); + const { kbnConfig } = config; const [hostname, port, basePath, protocol] = [ - config.get('xpack.reporting.kibanaServer.hostname') || config.get('server.host'), - config.get('xpack.reporting.kibanaServer.port') || config.get('server.port'), - config.get('server.basePath'), - config.get('xpack.reporting.kibanaServer.protocol') || server.info.protocol, + config.get('kibanaServer', 'hostname'), + config.get('kibanaServer', 'port'), + kbnConfig.get('server', 'basePath'), + config.get('kibanaServer', 'protocol'), ] as [string, number, string, string]; const conditionalHeaders: ConditionalHeaders = { headers: filteredHeaders, conditions: { - hostname: hostname.toLowerCase(), + hostname: hostname ? hostname.toLowerCase() : hostname, port, basePath, protocol, diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts index fa53f474dfba7..7c4c889e3e14f 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts @@ -5,16 +5,18 @@ */ import { ReportingCore } from '../../../server'; -import { createMockReportingCore, createMockServer } from '../../../test_helpers'; -import { ServerFacade } from '../../../types'; +import { createMockReportingCore } from '../../../test_helpers'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; import { getConditionalHeaders, getCustomLogo } from './index'; +const mockConfigGet = jest.fn().mockImplementation((key: string) => { + return 'localhost'; +}); +const mockConfig = { get: mockConfigGet, kbnConfig: { get: mockConfigGet } }; + let mockReportingPlugin: ReportingCore; -let mockServer: ServerFacade; beforeEach(async () => { mockReportingPlugin = await createMockReportingCore(); - mockServer = createMockServer(''); }); test(`gets logo from uiSettings`, async () => { @@ -37,14 +39,14 @@ test(`gets logo from uiSettings`, async () => { const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayloadPDF, filteredHeaders: permittedHeaders, - server: mockServer, + config: mockConfig, }); const { logo } = await getCustomLogo({ reporting: mockReportingPlugin, + config: mockConfig, job: {} as JobDocPayloadPDF, conditionalHeaders, - server: mockServer, }); expect(mockGet).toBeCalledWith('xpackReporting:customPdfLogo'); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts index 7af5edab41ab7..a13f992e7867c 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts @@ -5,23 +5,22 @@ */ import { UI_SETTINGS_CUSTOM_PDF_LOGO } from '../../../common/constants'; -import { ReportingCore } from '../../../server'; -import { ConditionalHeaders, ServerFacade } from '../../../types'; +import { ReportingConfig, ReportingCore } from '../../../server/types'; +import { ConditionalHeaders } from '../../../types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; // Logo is PDF only export const getCustomLogo = async ({ reporting, - server, + config, job, conditionalHeaders, }: { reporting: ReportingCore; - server: ServerFacade; + config: ReportingConfig; job: JobDocPayloadPDF; conditionalHeaders: ConditionalHeaders; }) => { - const serverBasePath: string = server.config().get('server.basePath'); - + const serverBasePath: string = config.kbnConfig.get('server', 'basePath'); const fakeRequest: any = { headers: conditionalHeaders.headers, // This is used by the spaces SavedObjectClientWrapper to determine the existing space. diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts index 27e772195f726..5f55617724ff6 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts @@ -4,29 +4,41 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createMockServer } from '../../../test_helpers'; -import { ServerFacade } from '../../../types'; +import { ReportingConfig } from '../../../server'; import { JobDocPayloadPNG } from '../../png/types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; import { getFullUrls } from './get_full_urls'; interface FullUrlsOpts { job: JobDocPayloadPNG & JobDocPayloadPDF; - server: ServerFacade; - conditionalHeaders: any; + config: ReportingConfig; } -let mockServer: any; +let mockConfig: ReportingConfig; +const getMockConfig = (mockConfigGet: jest.Mock) => { + return { + get: mockConfigGet, + kbnConfig: { get: mockConfigGet }, + }; +}; + beforeEach(() => { - mockServer = createMockServer(''); + const reportingConfig: Record = { + 'kibanaServer.hostname': 'localhost', + 'kibanaServer.port': 5601, + 'kibanaServer.protocol': 'http', + 'server.basePath': '/sbp', + }; + const mockConfigGet = jest.fn().mockImplementation((...keys: string[]) => { + return reportingConfig[keys.join('.') as string]; + }); + mockConfig = getMockConfig(mockConfigGet); }); +const getMockJob = (base: object) => base as JobDocPayloadPNG & JobDocPayloadPDF; + test(`fails if no URL is passed`, async () => { - const fn = () => - getFullUrls({ - job: {}, - server: mockServer, - } as FullUrlsOpts); + const fn = () => getFullUrls({ job: getMockJob({}), config: mockConfig } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"No valid URL fields found in Job Params! Expected \`job.relativeUrl: string\` or \`job.relativeUrls: string[]\`"` ); @@ -37,8 +49,8 @@ test(`fails if URLs are file-protocols for PNGs`, async () => { const relativeUrl = 'file://etc/passwd/#/something'; const fn = () => getFullUrls({ - job: { relativeUrl, forceNow }, - server: mockServer, + job: getMockJob({ relativeUrl, forceNow }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: file://etc/passwd/#/something"` @@ -51,8 +63,8 @@ test(`fails if URLs are absolute for PNGs`, async () => { 'http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something'; const fn = () => getFullUrls({ - job: { relativeUrl, forceNow }, - server: mockServer, + job: getMockJob({ relativeUrl, forceNow }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something"` @@ -64,11 +76,11 @@ test(`fails if URLs are file-protocols for PDF`, async () => { const relativeUrl = 'file://etc/passwd/#/something'; const fn = () => getFullUrls({ - job: { + job: getMockJob({ relativeUrls: [relativeUrl], forceNow, - }, - server: mockServer, + }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: file://etc/passwd/#/something"` @@ -81,11 +93,11 @@ test(`fails if URLs are absolute for PDF`, async () => { 'http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something'; const fn = () => getFullUrls({ - job: { + job: getMockJob({ relativeUrls: [relativeUrl], forceNow, - }, - server: mockServer, + }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something"` @@ -102,8 +114,8 @@ test(`fails if any URLs are absolute or file's for PDF`, async () => { const fn = () => getFullUrls({ - job: { relativeUrls, forceNow }, - server: mockServer, + job: getMockJob({ relativeUrls, forceNow }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something file://etc/passwd/#/something"` @@ -113,8 +125,8 @@ test(`fails if any URLs are absolute or file's for PDF`, async () => { test(`fails if URL does not route to a visualization`, async () => { const fn = () => getFullUrls({ - job: { relativeUrl: '/app/phoney' }, - server: mockServer, + job: getMockJob({ relativeUrl: '/app/phoney' }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"No valid hash in the URL! A hash is expected for the application to route to the intended visualization."` @@ -124,8 +136,8 @@ test(`fails if URL does not route to a visualization`, async () => { test(`adds forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const urls = await getFullUrls({ - job: { relativeUrl: '/app/kibana#/something', forceNow }, - server: mockServer, + job: getMockJob({ relativeUrl: '/app/kibana#/something', forceNow }), + config: mockConfig, } as FullUrlsOpts); expect(urls[0]).toEqual( @@ -137,8 +149,8 @@ test(`appends forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const urls = await getFullUrls({ - job: { relativeUrl: '/app/kibana#/something?_g=something', forceNow }, - server: mockServer, + job: getMockJob({ relativeUrl: '/app/kibana#/something?_g=something', forceNow }), + config: mockConfig, } as FullUrlsOpts); expect(urls[0]).toEqual( @@ -148,8 +160,8 @@ test(`appends forceNow to hash's query, if it exists`, async () => { test(`doesn't append forceNow query to url, if it doesn't exists`, async () => { const urls = await getFullUrls({ - job: { relativeUrl: '/app/kibana#/something' }, - server: mockServer, + job: getMockJob({ relativeUrl: '/app/kibana#/something' }), + config: mockConfig, } as FullUrlsOpts); expect(urls[0]).toEqual('http://localhost:5601/sbp/app/kibana#/something'); @@ -158,7 +170,7 @@ test(`doesn't append forceNow query to url, if it doesn't exists`, async () => { test(`adds forceNow to each of multiple urls`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const urls = await getFullUrls({ - job: { + job: getMockJob({ relativeUrls: [ '/app/kibana#/something_aaa', '/app/kibana#/something_bbb', @@ -166,8 +178,8 @@ test(`adds forceNow to each of multiple urls`, async () => { '/app/kibana#/something_ddd', ], forceNow, - }, - server: mockServer, + }), + config: mockConfig, } as FullUrlsOpts); expect(urls).toEqual([ diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts index ca64d8632dbfe..c4b6f31019fdf 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts @@ -12,7 +12,7 @@ import { } from 'url'; import { getAbsoluteUrlFactory } from '../../../common/get_absolute_url'; import { validateUrls } from '../../../common/validate_urls'; -import { ServerFacade } from '../../../types'; +import { ReportingConfig } from '../../../server/types'; import { JobDocPayloadPNG } from '../../png/types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; @@ -24,19 +24,23 @@ function isPdfJob(job: JobDocPayloadPNG | JobDocPayloadPDF): job is JobDocPayloa } export function getFullUrls({ - server, + config, job, }: { - server: ServerFacade; + config: ReportingConfig; job: JobDocPayloadPDF | JobDocPayloadPNG; }) { - const config = server.config(); - + const [basePath, protocol, hostname, port] = [ + config.kbnConfig.get('server', 'basePath'), + config.get('kibanaServer', 'protocol'), + config.get('kibanaServer', 'hostname'), + config.get('kibanaServer', 'port'), + ] as string[]; const getAbsoluteUrl = getAbsoluteUrlFactory({ - defaultBasePath: config.get('server.basePath'), - protocol: config.get('xpack.reporting.kibanaServer.protocol') || server.info.protocol, - hostname: config.get('xpack.reporting.kibanaServer.hostname') || config.get('server.host'), - port: config.get('xpack.reporting.kibanaServer.port') || config.get('server.port'), + defaultBasePath: basePath, + protocol, + hostname, + port, }); // PDF and PNG job params put in the url differently diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts index 0cb83352d4606..07fceb603e451 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts @@ -3,17 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { ServerFacade } from '../../../types'; + +import { CaptureConfig } from '../../../server/types'; import { LayoutTypes } from '../constants'; import { Layout, LayoutParams } from './layout'; import { PreserveLayout } from './preserve_layout'; import { PrintLayout } from './print_layout'; -export function createLayout(server: ServerFacade, layoutParams?: LayoutParams): Layout { +export function createLayout(captureConfig: CaptureConfig, layoutParams?: LayoutParams): Layout { if (layoutParams && layoutParams.id === LayoutTypes.PRESERVE_LAYOUT) { return new PreserveLayout(layoutParams.dimensions); } // this is the default because some jobs won't have anything specified - return new PrintLayout(server); + return new PrintLayout(captureConfig); } diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts index 6007c2960057a..98d8dc2983653 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts @@ -3,11 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import path from 'path'; import { EvaluateFn, SerializableOrJSHandle } from 'puppeteer'; -import { LevelLogger } from '../../../server/lib'; import { HeadlessChromiumDriver } from '../../../server/browsers'; -import { ServerFacade } from '../../../types'; +import { LevelLogger } from '../../../server/lib'; +import { ReportingConfigType } from '../../../server/core'; import { LayoutTypes } from '../constants'; import { getDefaultLayoutSelectors, Layout, LayoutSelectorDictionary, Size } from './layout'; import { CaptureConfig } from './types'; @@ -20,9 +21,9 @@ export class PrintLayout extends Layout { public readonly groupCount = 2; private captureConfig: CaptureConfig; - constructor(server: ServerFacade) { + constructor(captureConfig: ReportingConfigType['capture']) { super(LayoutTypes.PRINT); - this.captureConfig = server.config().get('xpack.reporting.capture'); + this.captureConfig = captureConfig; } public getCssOverridesPath() { diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts index 16eb433e8a75e..57d025890d3e2 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts @@ -7,17 +7,16 @@ import { i18n } from '@kbn/i18n'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; -import { ServerFacade } from '../../../../types'; +import { CaptureConfig } from '../../../../server/types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_GETNUMBEROFITEMS, CONTEXT_READMETADATA } from './constants'; export const getNumberOfItems = async ( - server: ServerFacade, + captureConfig: CaptureConfig, browser: HeadlessBrowser, layout: LayoutInstance, logger: LevelLogger ): Promise => { - const config = server.config(); const { renderComplete: renderCompleteSelector, itemsCountAttribute } = layout.selectors; let itemsCount: number; @@ -33,7 +32,7 @@ export const getNumberOfItems = async ( // we have to use this hint to wait for all of them await browser.waitForSelector( `${renderCompleteSelector},[${itemsCountAttribute}]`, - { timeout: config.get('xpack.reporting.capture.timeouts.waitForElements') }, + { timeout: captureConfig.timeouts.waitForElements }, { context: CONTEXT_READMETADATA }, logger ); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts index 13d07bcdd6baf..75ac3dca4ffa0 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts @@ -19,12 +19,9 @@ import * as Rx from 'rxjs'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { loggingServiceMock } from '../../../../../../../../src/core/server/mocks'; import { LevelLogger } from '../../../../server/lib'; -import { - createMockBrowserDriverFactory, - createMockLayoutInstance, - createMockServer, -} from '../../../../test_helpers'; +import { createMockBrowserDriverFactory, createMockLayoutInstance } from '../../../../test_helpers'; import { ConditionalHeaders, HeadlessChromiumDriver } from '../../../../types'; +import { CaptureConfig } from '../../../../server/types'; import { screenshotsObservableFactory } from './observable'; import { ElementsPositionAndAttribute } from './types'; @@ -34,8 +31,8 @@ import { ElementsPositionAndAttribute } from './types'; const mockLogger = jest.fn(loggingServiceMock.create); const logger = new LevelLogger(mockLogger()); -const __LEGACY = createMockServer({ settings: { 'xpack.reporting.capture': { loadDelay: 13 } } }); -const mockLayout = createMockLayoutInstance(__LEGACY); +const mockConfig = { timeouts: { openUrl: 13 } } as CaptureConfig; +const mockLayout = createMockLayoutInstance(mockConfig); /* * Tests @@ -48,7 +45,7 @@ describe('Screenshot Observable Pipeline', () => { }); it('pipelines a single url into screenshot and timeRange', async () => { - const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); const result = await getScreenshots$({ logger, urls: ['/welcome/home/start/index.htm'], @@ -86,7 +83,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); const result = await getScreenshots$({ logger, urls: ['/welcome/home/start/index2.htm', '/welcome/home/start/index.php3?page=./home.php'], @@ -136,7 +133,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); const getScreenshot = async () => { return await getScreenshots$({ logger, @@ -197,7 +194,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); const getScreenshot = async () => { return await getScreenshots$({ logger, diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts index 44c04c763f840..53a11c18abd79 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts @@ -6,24 +6,22 @@ import * as Rx from 'rxjs'; import { catchError, concatMap, first, mergeMap, take, takeUntil, toArray } from 'rxjs/operators'; -import { CaptureConfig, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; +import { CaptureConfig } from '../../../../server/types'; +import { HeadlessChromiumDriverFactory } from '../../../../types'; import { getElementPositionAndAttributes } from './get_element_position_data'; import { getNumberOfItems } from './get_number_of_items'; import { getScreenshots } from './get_screenshots'; import { getTimeRange } from './get_time_range'; +import { injectCustomCss } from './inject_css'; import { openUrl } from './open_url'; import { ScreenSetupData, ScreenshotObservableOpts, ScreenshotResults } from './types'; import { waitForRenderComplete } from './wait_for_render'; import { waitForVisualizations } from './wait_for_visualizations'; -import { injectCustomCss } from './inject_css'; export function screenshotsObservableFactory( - server: ServerFacade, + captureConfig: CaptureConfig, browserDriverFactory: HeadlessChromiumDriverFactory ) { - const config = server.config(); - const captureConfig: CaptureConfig = config.get('xpack.reporting.capture'); - return function screenshotsObservable({ logger, urls, @@ -41,13 +39,13 @@ export function screenshotsObservableFactory( mergeMap(({ driver, exit$ }) => { const setup$: Rx.Observable = Rx.of(1).pipe( takeUntil(exit$), - mergeMap(() => openUrl(server, driver, url, conditionalHeaders, logger)), - mergeMap(() => getNumberOfItems(server, driver, layout, logger)), + mergeMap(() => openUrl(captureConfig, driver, url, conditionalHeaders, logger)), + mergeMap(() => getNumberOfItems(captureConfig, driver, layout, logger)), mergeMap(async itemsCount => { const viewport = layout.getViewport(itemsCount); await Promise.all([ driver.setViewport(viewport, logger), - waitForVisualizations(server, driver, itemsCount, layout, logger), + waitForVisualizations(captureConfig, driver, itemsCount, layout, logger), ]); }), mergeMap(async () => { @@ -60,7 +58,7 @@ export function screenshotsObservableFactory( await layout.positionElements(driver, logger); } - await waitForRenderComplete(driver, layout, captureConfig, logger); + await waitForRenderComplete(captureConfig, driver, layout, logger); }), mergeMap(async () => { return await Promise.all([ diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts index fbae1f91a7a6a..a484dfb243563 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts @@ -5,27 +5,26 @@ */ import { i18n } from '@kbn/i18n'; -import { ConditionalHeaders, ServerFacade } from '../../../../types'; -import { LevelLogger } from '../../../../server/lib'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; +import { LevelLogger } from '../../../../server/lib'; +import { CaptureConfig } from '../../../../server/types'; +import { ConditionalHeaders } from '../../../../types'; import { PAGELOAD_SELECTOR } from '../../constants'; export const openUrl = async ( - server: ServerFacade, + captureConfig: CaptureConfig, browser: HeadlessBrowser, url: string, conditionalHeaders: ConditionalHeaders, logger: LevelLogger ): Promise => { - const config = server.config(); - try { await browser.open( url, { conditionalHeaders, waitForSelector: PAGELOAD_SELECTOR, - timeout: config.get('xpack.reporting.capture.timeouts.openUrl'), + timeout: captureConfig.timeouts.openUrl, }, logger ); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts index ab81a952f345c..76613c2d631d6 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElementPosition, ConditionalHeaders } from '../../../../types'; import { LevelLogger } from '../../../../server/lib'; +import { ConditionalHeaders, ElementPosition } from '../../../../types'; import { LayoutInstance } from '../../layouts/layout'; export interface ScreenshotObservableOpts { diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts index 2f6dc2829dfd8..069896c8d9e90 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts @@ -5,16 +5,16 @@ */ import { i18n } from '@kbn/i18n'; -import { CaptureConfig } from '../../../../types'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; +import { CaptureConfig } from '../../../../server/types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_WAITFORRENDER } from './constants'; export const waitForRenderComplete = async ( + captureConfig: CaptureConfig, browser: HeadlessBrowser, layout: LayoutInstance, - captureConfig: CaptureConfig, logger: LevelLogger ) => { logger.debug( diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts index 93ad40026dff8..7960e1552e559 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts @@ -5,9 +5,9 @@ */ import { i18n } from '@kbn/i18n'; -import { ServerFacade } from '../../../../types'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; +import { CaptureConfig } from '../../../../server/types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants'; @@ -23,13 +23,12 @@ const getCompletedItemsCount = ({ renderCompleteSelector }: SelectorArgs) => { * 3. Wait for the render complete event to be fired once for each item */ export const waitForVisualizations = async ( - server: ServerFacade, + captureConfig: CaptureConfig, browser: HeadlessBrowser, itemsCount: number, layout: LayoutInstance, logger: LevelLogger ): Promise => { - const config = server.config(); const { renderComplete: renderCompleteSelector } = layout.selectors; logger.debug( @@ -45,7 +44,7 @@ export const waitForVisualizations = async ( fn: getCompletedItemsCount, args: [{ renderCompleteSelector }], toEqual: itemsCount, - timeout: config.get('xpack.reporting.capture.timeouts.renderComplete'), + timeout: captureConfig.timeouts.renderComplete, }, { context: CONTEXT_WAITFORELEMENTSTOBEINDOM }, logger diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts index 7ea67277015ab..b87403ac74f89 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts @@ -11,14 +11,14 @@ import { CreateJobFactory, ESQueueCreateJobFn, RequestFacade, - ServerFacade, } from '../../../types'; import { JobParamsDiscoverCsv } from '../types'; export const createJobFactory: CreateJobFactory> = function createJobFactoryFn(reporting: ReportingCore, server: ServerFacade) { - const crypto = cryptoFactory(server); +>> = async function createJobFactoryFn(reporting: ReportingCore) { + const config = await reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); return async function createJob( jobParams: JobParamsDiscoverCsv, diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js index f12916b734dbf..7dfa705901fbe 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js @@ -36,11 +36,12 @@ describe('CSV Execute Job', function() { let defaultElasticsearchResponse; let encryptedHeaders; - let cancellationToken; - let mockReportingPlugin; - let mockServer; let clusterStub; + let configGetStub; + let mockReportingConfig; + let mockReportingPlugin; let callAsCurrentUserStub; + let cancellationToken; const mockElasticsearch = { dataClient: { @@ -58,7 +59,17 @@ describe('CSV Execute Job', function() { beforeEach(async function() { mockReportingPlugin = await createMockReportingCore(); - mockReportingPlugin.getUiSettingsServiceFactory = () => mockUiSettingsClient; + + configGetStub = sinon.stub(); + configGetStub.withArgs('encryptionKey').returns(encryptionKey); + configGetStub.withArgs('csv', 'maxSizeBytes').returns(1024 * 1000); // 1mB + configGetStub.withArgs('csv', 'scroll').returns({}); + mockReportingConfig = { get: configGetStub, kbnConfig: { get: configGetStub } }; + + mockReportingPlugin.getConfig = () => Promise.resolve(mockReportingConfig); + mockReportingPlugin.getUiSettingsServiceFactory = () => Promise.resolve(mockUiSettingsClient); + mockReportingPlugin.getElasticsearchService = () => Promise.resolve(mockElasticsearch); + cancellationToken = new CancellationToken(); defaultElasticsearchResponse = { @@ -75,7 +86,6 @@ describe('CSV Execute Job', function() { .stub(clusterStub, 'callAsCurrentUser') .resolves(defaultElasticsearchResponse); - const configGetStub = sinon.stub(); mockUiSettingsClient.get.withArgs('csv:separator').returns(','); mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(true); @@ -93,36 +103,11 @@ describe('CSV Execute Job', function() { return fieldFormatsRegistry; }, }); - - mockServer = { - config: function() { - return { - get: configGetStub, - }; - }, - }; - mockServer - .config() - .get.withArgs('xpack.reporting.encryptionKey') - .returns(encryptionKey); - mockServer - .config() - .get.withArgs('xpack.reporting.csv.maxSizeBytes') - .returns(1024 * 1000); // 1mB - mockServer - .config() - .get.withArgs('xpack.reporting.csv.scroll') - .returns({}); }); describe('basic Elasticsearch call behavior', function() { it('should decrypt encrypted headers and pass to callAsCurrentUser', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -138,12 +123,7 @@ describe('CSV Execute Job', function() { testBody: true, }; - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const job = { headers: encryptedHeaders, fields: [], @@ -170,12 +150,7 @@ describe('CSV Execute Job', function() { _scroll_id: scrollId, }); callAsCurrentUserStub.onSecondCall().resolves(defaultElasticsearchResponse); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -189,12 +164,7 @@ describe('CSV Execute Job', function() { }); it('should not execute scroll if there are no hits from the search', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -224,12 +194,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -264,12 +229,7 @@ describe('CSV Execute Job', function() { _scroll_id: lastScrollId, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -297,12 +257,7 @@ describe('CSV Execute Job', function() { _scroll_id: lastScrollId, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -321,10 +276,7 @@ describe('CSV Execute Job', function() { describe('Cells with formula values', () => { it('returns `csv_contains_formulas` when cells contain formulas', async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.checkForFormulas') - .returns(true); + configGetStub.withArgs('csv', 'checkForFormulas').returns(true); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { one: '=SUM(A1:A2)', two: 'bar' } }], @@ -332,12 +284,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -354,10 +301,7 @@ describe('CSV Execute Job', function() { }); it('returns warnings when headings contain formulas', async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.checkForFormulas') - .returns(true); + configGetStub.withArgs('csv', 'checkForFormulas').returns(true); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { '=SUM(A1:A2)': 'foo', two: 'bar' } }], @@ -365,12 +309,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['=SUM(A1:A2)', 'two'], @@ -387,10 +326,7 @@ describe('CSV Execute Job', function() { }); it('returns no warnings when cells have no formulas', async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.checkForFormulas') - .returns(true); + configGetStub.withArgs('csv', 'checkForFormulas').returns(true); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -398,12 +334,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -420,10 +351,7 @@ describe('CSV Execute Job', function() { }); it('returns no warnings when configured not to', async () => { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.checkForFormulas') - .returns(false); + configGetStub.withArgs('csv', 'checkForFormulas').returns(false); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { one: '=SUM(A1:A2)', two: 'bar' } }], @@ -431,12 +359,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -456,12 +379,7 @@ describe('CSV Execute Job', function() { describe('Elasticsearch call errors', function() { it('should reject Promise if search call errors out', async function() { callAsCurrentUserStub.rejects(new Error()); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -480,12 +398,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); callAsCurrentUserStub.onSecondCall().rejects(new Error()); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -506,12 +419,7 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -532,12 +440,7 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -565,12 +468,7 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -598,12 +496,7 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -639,12 +532,7 @@ describe('CSV Execute Job', function() { }); it('should stop calling Elasticsearch when cancellationToken.cancel is called', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); executeJob( 'job345', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -659,12 +547,7 @@ describe('CSV Execute Job', function() { }); it(`shouldn't call clearScroll if it never got a scrollId`, async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); executeJob( 'job345', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -678,12 +561,7 @@ describe('CSV Execute Job', function() { }); it('should call clearScroll if it got a scrollId', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); executeJob( 'job345', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -701,12 +579,7 @@ describe('CSV Execute Job', function() { describe('csv content', function() { it('should write column headers to output, even if there are no results', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -718,12 +591,7 @@ describe('CSV Execute Job', function() { it('should use custom uiSettings csv:separator for header', async function() { mockUiSettingsClient.get.withArgs('csv:separator').returns(';'); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -735,12 +603,7 @@ describe('CSV Execute Job', function() { it('should escape column headers if uiSettings csv:quoteValues is true', async function() { mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(true); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one and a half', 'two', 'three-and-four', 'five & six'], @@ -752,12 +615,7 @@ describe('CSV Execute Job', function() { it(`shouldn't escape column headers if uiSettings csv:quoteValues is false`, async function() { mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(false); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one and a half', 'two', 'three-and-four', 'five & six'], @@ -768,12 +626,7 @@ describe('CSV Execute Job', function() { }); it('should write column headers to output, when there are results', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ one: '1', two: '2' }], @@ -793,12 +646,7 @@ describe('CSV Execute Job', function() { }); it('should use comma separated values of non-nested fields from _source', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -819,12 +667,7 @@ describe('CSV Execute Job', function() { }); it('should concatenate the hits from multiple responses', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -852,12 +695,7 @@ describe('CSV Execute Job', function() { }); it('should use field formatters to format fields', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -897,17 +735,9 @@ describe('CSV Execute Job', function() { let maxSizeReached; beforeEach(async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.maxSizeBytes') - .returns(1); - - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + configGetStub.withArgs('csv', 'maxSizeBytes').returns(1); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -935,17 +765,9 @@ describe('CSV Execute Job', function() { let maxSizeReached; beforeEach(async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.maxSizeBytes') - .returns(9); - - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + configGetStub.withArgs('csv', 'maxSizeBytes').returns(9); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -973,10 +795,7 @@ describe('CSV Execute Job', function() { let maxSizeReached; beforeEach(async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.maxSizeBytes') - .returns(9); + configGetStub.withArgs('csv', 'maxSizeBytes').returns(9); callAsCurrentUserStub.onFirstCall().returns({ hits: { @@ -985,12 +804,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -1020,10 +834,7 @@ describe('CSV Execute Job', function() { beforeEach(async function() { mockReportingPlugin.getUiSettingsServiceFactory = () => mockUiSettingsClient; - mockServer - .config() - .get.withArgs('xpack.reporting.csv.maxSizeBytes') - .returns(18); + configGetStub.withArgs('csv', 'maxSizeBytes').returns(18); callAsCurrentUserStub.onFirstCall().returns({ hits: { @@ -1032,12 +843,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -1065,10 +871,7 @@ describe('CSV Execute Job', function() { describe('scroll settings', function() { it('passes scroll duration to initial search call', async function() { const scrollDuration = 'test'; - mockServer - .config() - .get.withArgs('xpack.reporting.csv.scroll') - .returns({ duration: scrollDuration }); + configGetStub.withArgs('csv', 'scroll').returns({ duration: scrollDuration }); callAsCurrentUserStub.onFirstCall().returns({ hits: { @@ -1077,12 +880,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -1099,10 +897,7 @@ describe('CSV Execute Job', function() { it('passes scroll size to initial search call', async function() { const scrollSize = 100; - mockServer - .config() - .get.withArgs('xpack.reporting.csv.scroll') - .returns({ size: scrollSize }); + configGetStub.withArgs('csv', 'scroll').returns({ size: scrollSize }); callAsCurrentUserStub.onFirstCall().resolves({ hits: { @@ -1111,12 +906,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -1133,10 +923,7 @@ describe('CSV Execute Job', function() { it('passes scroll duration to subsequent scroll call', async function() { const scrollDuration = 'test'; - mockServer - .config() - .get.withArgs('xpack.reporting.csv.scroll') - .returns({ duration: scrollDuration }); + configGetStub.withArgs('csv', 'scroll').returns({ duration: scrollDuration }); callAsCurrentUserStub.onFirstCall().resolves({ hits: { @@ -1145,12 +932,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts index 1579985891053..a8249e5810d3c 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts @@ -6,32 +6,26 @@ import { i18n } from '@kbn/i18n'; import Hapi from 'hapi'; -import { - ElasticsearchServiceSetup, - IUiSettingsClient, - KibanaRequest, -} from '../../../../../../../src/core/server'; +import { IUiSettingsClient, KibanaRequest } from '../../../../../../../src/core/server'; import { CSV_JOB_TYPE } from '../../../common/constants'; import { ReportingCore } from '../../../server'; import { cryptoFactory } from '../../../server/lib'; import { getFieldFormats } from '../../../server/services'; -import { ESQueueWorkerExecuteFn, ExecuteJobFactory, Logger, ServerFacade } from '../../../types'; +import { ESQueueWorkerExecuteFn, ExecuteJobFactory, Logger } from '../../../types'; import { JobDocPayloadDiscoverCsv } from '../types'; import { fieldFormatMapFactory } from './lib/field_format_map'; import { createGenerateCsv } from './lib/generate_csv'; export const executeJobFactory: ExecuteJobFactory> = async function executeJobFactoryFn( - reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, - parentLogger: Logger -) { - const crypto = cryptoFactory(server); - const config = server.config(); +>> = async function executeJobFactoryFn(reporting: ReportingCore, parentLogger: Logger) { + const [config, elasticsearch] = await Promise.all([ + reporting.getConfig(), + reporting.getElasticsearchService(), + ]); + const crypto = cryptoFactory(config.get('encryptionKey')); const logger = parentLogger.clone([CSV_JOB_TYPE, 'execute-job']); - const serverBasePath = config.get('server.basePath'); + const serverBasePath = config.kbnConfig.get('server', 'basePath'); return async function executeJob( jobId: string, @@ -131,9 +125,9 @@ export const executeJobFactory: ExecuteJobFactory) { const response = await request; diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts b/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts index 842330fa7c93f..529c195486bc6 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts @@ -5,7 +5,8 @@ */ import { CancellationToken } from '../../common/cancellation_token'; -import { JobDocPayload, JobParamPostPayload, ConditionalHeaders, RequestFacade } from '../../types'; +import { ScrollConfig } from '../../server/types'; +import { JobDocPayload, JobParamPostPayload } from '../../types'; interface DocValueField { field: string; @@ -106,7 +107,7 @@ export interface GenerateCsvParams { quoteValues: boolean; timezone: string | null; maxSizeBytes: number; - scroll: { duration: string; size: number }; + scroll: ScrollConfig; checkForFormulas?: boolean; }; } diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts index 17072d311b35f..15a1c3e0a9fad 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts @@ -5,18 +5,11 @@ */ import { notFound, notImplemented } from 'boom'; -import { ElasticsearchServiceSetup } from 'kibana/server'; import { get } from 'lodash'; import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../../common/constants'; import { ReportingCore } from '../../../../server'; import { cryptoFactory } from '../../../../server/lib'; -import { - CreateJobFactory, - ImmediateCreateJobFn, - Logger, - RequestFacade, - ServerFacade, -} from '../../../../types'; +import { CreateJobFactory, ImmediateCreateJobFn, Logger, RequestFacade } from '../../../../types'; import { JobDocPayloadPanelCsv, JobParamsPanelCsv, @@ -37,13 +30,9 @@ interface VisData { export const createJobFactory: CreateJobFactory> = function createJobFactoryFn( - reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, - parentLogger: Logger -) { - const crypto = cryptoFactory(server); +>> = async function createJobFactoryFn(reporting: ReportingCore, parentLogger: Logger) { + const config = await reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'create-job']); return async function createJob( diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts index 6bb3e73fcfe84..debcdb47919f1 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts @@ -5,7 +5,6 @@ */ import { i18n } from '@kbn/i18n'; -import { ElasticsearchServiceSetup } from 'kibana/server'; import { CONTENT_TYPE_CSV, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; import { ReportingCore } from '../../../server'; import { cryptoFactory } from '../../../server/lib'; @@ -15,7 +14,6 @@ import { JobDocOutput, Logger, RequestFacade, - ServerFacade, } from '../../../types'; import { CsvResultFromSearch } from '../../csv/types'; import { FakeRequest, JobDocPayloadPanelCsv, JobParamsPanelCsv, SearchPanel } from '../types'; @@ -23,15 +21,11 @@ import { createGenerateCsv } from './lib'; export const executeJobFactory: ExecuteJobFactory> = async function executeJobFactoryFn( - reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, - parentLogger: Logger -) { - const crypto = cryptoFactory(server); +>> = async function executeJobFactoryFn(reporting: ReportingCore, parentLogger: Logger) { + const config = await reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'execute-job']); - const generateCsv = createGenerateCsv(reporting, server, elasticsearch, parentLogger); + const generateCsv = await createGenerateCsv(reporting, parentLogger); return async function executeJob( jobId: string | null, @@ -57,11 +51,11 @@ export const executeJobFactory: ExecuteJobFactory; const serializedEncryptedHeaders = job.headers; try { decryptedHeaders = await crypto.decrypt(serializedEncryptedHeaders); @@ -79,10 +73,7 @@ export const executeJobFactory: ExecuteJobFactory { export async function generateCsvSearch( req: RequestFacade, reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, logger: Logger, searchPanel: SearchPanel, jobParams: JobParamsDiscoverCsv @@ -159,11 +153,15 @@ export async function generateCsvSearch( }, }; + const [elasticsearch, config] = await Promise.all([ + reporting.getElasticsearchService(), + reporting.getConfig(), + ]); + const { callAsCurrentUser } = elasticsearch.dataClient.asScoped( KibanaRequest.from(req.getRawRequest()) ); const callCluster = (...params: [string, object]) => callAsCurrentUser(...params); - const config = server.config(); const uiSettings = await getUiSettings(uiConfig); const generateCsvParams: GenerateCsvParams = { @@ -176,8 +174,8 @@ export async function generateCsvSearch( cancellationToken: new CancellationToken(), settings: { ...uiSettings, - maxSizeBytes: config.get('xpack.reporting.csv.maxSizeBytes'), - scroll: config.get('xpack.reporting.csv.scroll'), + maxSizeBytes: config.get('csv', 'maxSizeBytes'), + scroll: config.get('csv', 'scroll'), timezone, }, }; diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts index 6a7d5f336e238..ab14d2dd8a660 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts @@ -4,11 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { JobParamPostPayload, JobDocPayload, ServerFacade } from '../../types'; +import { JobDocPayload, JobParamPostPayload } from '../../types'; export interface FakeRequest { - headers: any; - server: ServerFacade; + headers: Record; } export interface JobParamsPostPayloadPanelCsv extends JobParamPostPayload { diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts index a6911e1f14704..9aac612677094 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts @@ -12,14 +12,14 @@ import { CreateJobFactory, ESQueueCreateJobFn, RequestFacade, - ServerFacade, } from '../../../../types'; import { JobParamsPNG } from '../../types'; export const createJobFactory: CreateJobFactory> = function createJobFactoryFn(reporting: ReportingCore, server: ServerFacade) { - const crypto = cryptoFactory(server); +>> = async function createJobFactoryFn(reporting: ReportingCore) { + const config = await reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); return async function createJob( { objectType, title, relativeUrl, browserTimezone, layout }: JobParamsPNG, diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js index e2e6ba1b89096..267321d33809d 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js @@ -5,7 +5,6 @@ */ import * as Rx from 'rxjs'; -import { memoize } from 'lodash'; import { createMockReportingCore } from '../../../../test_helpers'; import { cryptoFactory } from '../../../../server/lib/crypto'; import { executeJobFactory } from './index'; @@ -14,63 +13,70 @@ import { LevelLogger } from '../../../../server/lib'; jest.mock('../lib/generate_png', () => ({ generatePngObservableFactory: jest.fn() })); +let mockReporting; +let mockReportingConfig; + const cancellationToken = { on: jest.fn(), }; -let config; -let mockServer; -let mockReporting; +const mockLoggerFactory = { + get: jest.fn().mockImplementation(() => ({ + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + })), +}; +const getMockLogger = () => new LevelLogger(mockLoggerFactory); + +const mockEncryptionKey = 'abcabcsecuresecret'; +const encryptHeaders = async headers => { + const crypto = cryptoFactory(mockEncryptionKey); + return await crypto.encrypt(headers); +}; beforeEach(async () => { mockReporting = await createMockReportingCore(); - config = { - 'xpack.reporting.encryptionKey': 'testencryptionkey', + const kbnConfig = { 'server.basePath': '/sbp', - 'server.host': 'localhost', - 'server.port': 5601, }; - mockServer = { - config: memoize(() => ({ get: jest.fn() })), - info: { - protocol: 'http', + const reportingConfig = { + encryptionKey: mockEncryptionKey, + 'kibanaServer.hostname': 'localhost', + 'kibanaServer.port': 5601, + 'kibanaServer.protocol': 'http', + }; + + const mockGetConfig = jest.fn(); + mockReportingConfig = { + get: (...keys) => reportingConfig[keys.join('.')], + kbnConfig: { get: (...keys) => kbnConfig[keys.join('.')] }, + }; + mockGetConfig.mockImplementation(() => Promise.resolve(mockReportingConfig)); + mockReporting.getConfig = mockGetConfig; + + const mockElasticsearch = { + dataClient: { + asScoped: () => ({ callAsCurrentUser: jest.fn() }), }, }; - mockServer.config().get.mockImplementation(key => { - return config[key]; - }); + const mockGetElasticsearch = jest.fn(); + mockGetElasticsearch.mockImplementation(() => Promise.resolve(mockElasticsearch)); + mockReporting.getElasticsearchService = mockGetElasticsearch; generatePngObservableFactory.mockReturnValue(jest.fn()); }); afterEach(() => generatePngObservableFactory.mockReset()); -const mockElasticsearch = { - dataClient: { - asScoped: () => ({ callAsCurrentUser: jest.fn() }), - }, -}; - -const getMockLogger = () => new LevelLogger(); - -const encryptHeaders = async headers => { - const crypto = cryptoFactory(mockServer); - return await crypto.encrypt(headers); -}; - test(`passes browserTimezone to generatePng`, async () => { const encryptedHeaders = await encryptHeaders({}); const generatePngObservable = generatePngObservableFactory(); generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); - const executeJob = await executeJobFactory( - mockReporting, - mockServer, - mockElasticsearch, - getMockLogger() - ); + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); const browserTimezone = 'UTC'; await executeJob( 'pngJobId', @@ -88,15 +94,7 @@ test(`passes browserTimezone to generatePng`, async () => { }); test(`returns content_type of application/png`, async () => { - const executeJob = await executeJobFactory( - mockReporting, - mockServer, - mockElasticsearch, - getMockLogger(), - { - browserDriverFactory: {}, - } - ); + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); const generatePngObservable = generatePngObservableFactory(); @@ -116,15 +114,7 @@ test(`returns content of generatePng getBuffer base64 encoded`, async () => { const generatePngObservable = generatePngObservableFactory(); generatePngObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); - const executeJob = await executeJobFactory( - mockReporting, - mockServer, - mockElasticsearch, - getMockLogger(), - { - browserDriverFactory: {}, - } - ); + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); const { content } = await executeJob( 'pngJobId', diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts index 8670f0027af89..c53c20efec247 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts @@ -4,18 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchServiceSetup } from 'kibana/server'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; import { PNG_JOB_TYPE } from '../../../../common/constants'; import { ReportingCore } from '../../../../server'; -import { - ESQueueWorkerExecuteFn, - ExecuteJobFactory, - JobDocOutput, - Logger, - ServerFacade, -} from '../../../../types'; +import { ESQueueWorkerExecuteFn, ExecuteJobFactory, JobDocOutput, Logger } from '../../../../types'; import { decryptJobHeaders, getConditionalHeaders, @@ -29,22 +22,24 @@ type QueuedPngExecutorFactory = ExecuteJobFactory = Rx.of(1).pipe( - mergeMap(() => decryptJobHeaders({ server, job, logger })), + mergeMap(() => decryptJobHeaders({ encryptionKey, job, logger })), map(decryptedHeaders => omitBlacklistedHeaders({ job, decryptedHeaders })), - map(filteredHeaders => getConditionalHeaders({ server, job, filteredHeaders })), + map(filteredHeaders => getConditionalHeaders({ config, job, filteredHeaders })), mergeMap(conditionalHeaders => { - const urls = getFullUrls({ server, job }); + const urls = getFullUrls({ config, job }); const hashUrl = urls[0]; return generatePngObservable( jobLogger, diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts index 88e91982adc63..a15541d99f6fb 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts @@ -7,17 +7,18 @@ import * as Rx from 'rxjs'; import { map } from 'rxjs/operators'; import { LevelLogger } from '../../../../server/lib'; -import { ConditionalHeaders, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; +import { CaptureConfig } from '../../../../server/types'; +import { ConditionalHeaders, HeadlessChromiumDriverFactory } from '../../../../types'; import { LayoutParams } from '../../../common/layouts/layout'; import { PreserveLayout } from '../../../common/layouts/preserve_layout'; import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; import { ScreenshotResults } from '../../../common/lib/screenshots/types'; export function generatePngObservableFactory( - server: ServerFacade, + captureConfig: CaptureConfig, browserDriverFactory: HeadlessChromiumDriverFactory ) { - const screenshotsObservable = screenshotsObservableFactory(server, browserDriverFactory); + const screenshotsObservable = screenshotsObservableFactory(captureConfig, browserDriverFactory); return function generatePngObservable( logger: LevelLogger, diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts index 656c99991e1f6..8e1d5404a5984 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts @@ -12,14 +12,14 @@ import { CreateJobFactory, ESQueueCreateJobFn, RequestFacade, - ServerFacade, } from '../../../../types'; import { JobParamsPDF } from '../../types'; export const createJobFactory: CreateJobFactory> = function createJobFactoryFn(reporting: ReportingCore, server: ServerFacade) { - const crypto = cryptoFactory(server); +>> = async function createJobFactoryFn(reporting: ReportingCore) { + const config = await reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); return async function createJobFn( { title, relativeUrls, browserTimezone, layout, objectType }: JobParamsPDF, diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js index 484842ba18f2a..29769108bf4ac 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js @@ -5,7 +5,6 @@ */ import * as Rx from 'rxjs'; -import { memoize } from 'lodash'; import { createMockReportingCore } from '../../../../test_helpers'; import { cryptoFactory } from '../../../../server/lib/crypto'; import { executeJobFactory } from './index'; @@ -14,57 +13,65 @@ import { LevelLogger } from '../../../../server/lib'; jest.mock('../lib/generate_pdf', () => ({ generatePdfObservableFactory: jest.fn() })); +let mockReporting; +let mockReportingConfig; + const cancellationToken = { on: jest.fn(), }; -let config; -let mockServer; -let mockReporting; +const mockLoggerFactory = { + get: jest.fn().mockImplementation(() => ({ + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + })), +}; +const getMockLogger = () => new LevelLogger(mockLoggerFactory); + +const mockEncryptionKey = 'testencryptionkey'; +const encryptHeaders = async headers => { + const crypto = cryptoFactory(mockEncryptionKey); + return await crypto.encrypt(headers); +}; beforeEach(async () => { mockReporting = await createMockReportingCore(); - config = { - 'xpack.reporting.encryptionKey': 'testencryptionkey', + const kbnConfig = { 'server.basePath': '/sbp', - 'server.host': 'localhost', - 'server.port': 5601, }; - mockServer = { - config: memoize(() => ({ get: jest.fn() })), - info: { - protocol: 'http', + const reportingConfig = { + encryptionKey: mockEncryptionKey, + 'kibanaServer.hostname': 'localhost', + 'kibanaServer.port': 5601, + 'kibanaServer.protocol': 'http', + }; + + const mockGetConfig = jest.fn(); + mockReportingConfig = { + get: (...keys) => reportingConfig[keys.join('.')], + kbnConfig: { get: (...keys) => kbnConfig[keys.join('.')] }, + }; + mockGetConfig.mockImplementation(() => Promise.resolve(mockReportingConfig)); + mockReporting.getConfig = mockGetConfig; + + const mockElasticsearch = { + dataClient: { + asScoped: () => ({ callAsCurrentUser: jest.fn() }), }, }; - mockServer.config().get.mockImplementation(key => { - return config[key]; - }); + const mockGetElasticsearch = jest.fn(); + mockGetElasticsearch.mockImplementation(() => Promise.resolve(mockElasticsearch)); + mockReporting.getElasticsearchService = mockGetElasticsearch; generatePdfObservableFactory.mockReturnValue(jest.fn()); }); afterEach(() => generatePdfObservableFactory.mockReset()); -const getMockLogger = () => new LevelLogger(); -const mockElasticsearch = { - dataClient: { - asScoped: () => ({ callAsCurrentUser: jest.fn() }), - }, -}; - -const encryptHeaders = async headers => { - const crypto = cryptoFactory(mockServer); - return await crypto.encrypt(headers); -}; - test(`returns content_type of application/pdf`, async () => { - const executeJob = await executeJobFactory( - mockReporting, - mockServer, - mockElasticsearch, - getMockLogger() - ); + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); const generatePdfObservable = generatePdfObservableFactory(); @@ -84,12 +91,7 @@ test(`returns content of generatePdf getBuffer base64 encoded`, async () => { const generatePdfObservable = generatePdfObservableFactory(); generatePdfObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); - const executeJob = await executeJobFactory( - mockReporting, - mockServer, - mockElasticsearch, - getMockLogger() - ); + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); const { content } = await executeJob( 'pdfJobId', diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts index 535c2dcd439a7..e614db46c5730 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts @@ -4,18 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchServiceSetup } from 'kibana/server'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; import { PDF_JOB_TYPE } from '../../../../common/constants'; import { ReportingCore } from '../../../../server'; -import { - ESQueueWorkerExecuteFn, - ExecuteJobFactory, - JobDocOutput, - Logger, - ServerFacade, -} from '../../../../types'; +import { ESQueueWorkerExecuteFn, ExecuteJobFactory, JobDocOutput, Logger } from '../../../../types'; import { decryptJobHeaders, getConditionalHeaders, @@ -30,23 +23,25 @@ type QueuedPdfExecutorFactory = ExecuteJobFactory = Rx.of(1).pipe( - mergeMap(() => decryptJobHeaders({ server, job, logger })), + mergeMap(() => decryptJobHeaders({ encryptionKey, job, logger })), map(decryptedHeaders => omitBlacklistedHeaders({ job, decryptedHeaders })), - map(filteredHeaders => getConditionalHeaders({ server, job, filteredHeaders })), - mergeMap(conditionalHeaders => getCustomLogo({ reporting, server, job, conditionalHeaders })), + map(filteredHeaders => getConditionalHeaders({ config, job, filteredHeaders })), + mergeMap(conditionalHeaders => getCustomLogo({ reporting, config, job, conditionalHeaders })), mergeMap(({ logo, conditionalHeaders }) => { - const urls = getFullUrls({ server, job }); + const urls = getFullUrls({ config, job }); const { browserTimezone, layout, title } = job; return generatePdfObservable( diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts index d78effaa1fc2f..7021fae983aa2 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts @@ -8,7 +8,8 @@ import { groupBy } from 'lodash'; import * as Rx from 'rxjs'; import { mergeMap } from 'rxjs/operators'; import { LevelLogger } from '../../../../server/lib'; -import { ConditionalHeaders, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; +import { ReportingConfigType } from '../../../../server/core'; +import { ConditionalHeaders, HeadlessChromiumDriverFactory } from '../../../../types'; import { createLayout } from '../../../common/layouts'; import { LayoutInstance, LayoutParams } from '../../../common/layouts/layout'; import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; @@ -27,10 +28,10 @@ const getTimeRange = (urlScreenshots: ScreenshotResults[]) => { }; export function generatePdfObservableFactory( - server: ServerFacade, + captureConfig: ReportingConfigType['capture'], browserDriverFactory: HeadlessChromiumDriverFactory ) { - const screenshotsObservable = screenshotsObservableFactory(server, browserDriverFactory); + const screenshotsObservable = screenshotsObservableFactory(captureConfig, browserDriverFactory); return function generatePdfObservable( logger: LevelLogger, @@ -41,7 +42,7 @@ export function generatePdfObservableFactory( layoutParams: LayoutParams, logo?: string ): Rx.Observable<{ buffer: Buffer; warnings: string[] }> { - const layout = createLayout(server, layoutParams) as LayoutInstance; + const layout = createLayout(captureConfig, layoutParams) as LayoutInstance; const screenshots$ = screenshotsObservable({ logger, urls, diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts index 0a9dcfe986ca6..e8dd3c5207d92 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { JobDocPayload } from '../../types'; import { LayoutInstance, LayoutParams } from '../common/layouts/layout'; -import { JobDocPayload, ServerFacade, RequestFacade } from '../../types'; // Job params: structure of incoming user request data, after being parsed from RISON export interface JobParamsPDF { diff --git a/x-pack/legacy/plugins/reporting/index.test.js b/x-pack/legacy/plugins/reporting/index.test.js deleted file mode 100644 index 0d9a717bd7d81..0000000000000 --- a/x-pack/legacy/plugins/reporting/index.test.js +++ /dev/null @@ -1,34 +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 { reporting } from './index'; -import { getConfigSchema } from '../../../test_utils'; - -// The snapshot records the number of cpus available -// to make the snapshot deterministic `os.cpus` needs to be mocked -// but the other members on `os` must remain untouched -jest.mock('os', () => { - const os = jest.requireActual('os'); - os.cpus = () => [{}, {}, {}, {}]; - return os; -}); - -// eslint-disable-next-line jest/valid-describe -const describeWithContext = describe.each([ - [{ dev: false, dist: false }], - [{ dev: true, dist: false }], - [{ dev: false, dist: true }], - [{ dev: true, dist: true }], -]); - -describeWithContext('config schema with context %j', context => { - it('produces correct config', async () => { - const schema = await getConfigSchema(reporting); - const value = await schema.validate({}, { context }); - value.capture.browser.chromium.disableSandbox = ''; - await expect(value).toMatchSnapshot(); - }); -}); diff --git a/x-pack/legacy/plugins/reporting/index.ts b/x-pack/legacy/plugins/reporting/index.ts index 89e98302cddc9..fb95e2c2edc24 100644 --- a/x-pack/legacy/plugins/reporting/index.ts +++ b/x-pack/legacy/plugins/reporting/index.ts @@ -8,21 +8,16 @@ import { i18n } from '@kbn/i18n'; import { Legacy } from 'kibana'; import { resolve } from 'path'; import { PLUGIN_ID, UI_SETTINGS_CUSTOM_PDF_LOGO } from './common/constants'; -import { config as reportingConfig } from './config'; import { legacyInit } from './server/legacy'; import { ReportingPluginSpecOptions } from './types'; -const kbToBase64Length = (kb: number) => { - return Math.floor((kb * 1024 * 8) / 6); -}; +const kbToBase64Length = (kb: number) => Math.floor((kb * 1024 * 8) / 6); export const reporting = (kibana: any) => { return new kibana.Plugin({ id: PLUGIN_ID, - configPrefix: 'xpack.reporting', publicDir: resolve(__dirname, 'public'), require: ['kibana', 'elasticsearch', 'xpack_main'], - config: reportingConfig, uiExports: { uiSettingDefaults: { @@ -49,14 +44,5 @@ export const reporting = (kibana: any) => { async init(server: Legacy.Server) { return legacyInit(server, this); }, - - deprecations({ unused }: any) { - return [ - unused('capture.concurrency'), - unused('capture.timeout'), - unused('capture.settleTime'), - unused('kibanaApp'), - ]; - }, } as ReportingPluginSpecOptions); }; diff --git a/x-pack/legacy/plugins/reporting/log_configuration.ts b/x-pack/legacy/plugins/reporting/log_configuration.ts index b07475df6304f..7aaed2038bd52 100644 --- a/x-pack/legacy/plugins/reporting/log_configuration.ts +++ b/x-pack/legacy/plugins/reporting/log_configuration.ts @@ -6,22 +6,23 @@ import getosSync, { LinuxOs } from 'getos'; import { promisify } from 'util'; -import { ServerFacade, Logger } from './types'; +import { BROWSER_TYPE } from './common/constants'; +import { CaptureConfig } from './server/types'; +import { Logger } from './types'; const getos = promisify(getosSync); -export async function logConfiguration(server: ServerFacade, logger: Logger) { - const config = server.config(); +export async function logConfiguration(captureConfig: CaptureConfig, logger: Logger) { + const { + browser: { + type: browserType, + chromium: { disableSandbox }, + }, + } = captureConfig; - const browserType = config.get('xpack.reporting.capture.browser.type'); logger.debug(`Browser type: ${browserType}`); - - if (browserType === 'chromium') { - logger.debug( - `Chromium sandbox disabled: ${config.get( - 'xpack.reporting.capture.browser.chromium.disableSandbox' - )}` - ); + if (browserType === BROWSER_TYPE) { + logger.debug(`Chromium sandbox disabled: ${disableSandbox}`); } const os = await getos(); diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts index dc79a6b9db2c1..a2f7a1f3ad0da 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts @@ -4,11 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BrowserConfig } from '../../../../types'; +import { CaptureConfig } from '../../../../server/types'; + +type ViewportConfig = CaptureConfig['viewport']; +type BrowserConfig = CaptureConfig['browser']['chromium']; interface LaunchArgs { userDataDir: BrowserConfig['userDataDir']; - viewport: BrowserConfig['viewport']; + viewport: ViewportConfig; disableSandbox: BrowserConfig['disableSandbox']; proxy: BrowserConfig['proxy']; } diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts index f90f2c7aee395..cb228150efbcd 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts @@ -19,7 +19,8 @@ import { import * as Rx from 'rxjs'; import { InnerSubscriber } from 'rxjs/internal/InnerSubscriber'; import { ignoreElements, map, mergeMap, tap } from 'rxjs/operators'; -import { BrowserConfig, CaptureConfig } from '../../../../types'; +import { BROWSER_TYPE } from '../../../../common/constants'; +import { CaptureConfig } from '../../../../server/types'; import { LevelLogger as Logger } from '../../../lib/level_logger'; import { safeChildProcess } from '../../safe_child_process'; import { HeadlessChromiumDriver } from '../driver'; @@ -28,7 +29,8 @@ import { puppeteerLaunch } from '../puppeteer'; import { args } from './args'; type binaryPath = string; -type ViewportConfig = BrowserConfig['viewport']; +type BrowserConfig = CaptureConfig['browser']['chromium']; +type ViewportConfig = CaptureConfig['viewport']; export class HeadlessChromiumDriverFactory { private binaryPath: binaryPath; @@ -37,15 +39,10 @@ export class HeadlessChromiumDriverFactory { private userDataDir: string; private getChromiumArgs: (viewport: ViewportConfig) => string[]; - constructor( - binaryPath: binaryPath, - logger: Logger, - browserConfig: BrowserConfig, - captureConfig: CaptureConfig - ) { + constructor(binaryPath: binaryPath, logger: Logger, captureConfig: CaptureConfig) { this.binaryPath = binaryPath; - this.browserConfig = browserConfig; this.captureConfig = captureConfig; + this.browserConfig = captureConfig.browser.chromium; this.userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chromium-')); this.getChromiumArgs = (viewport: ViewportConfig) => @@ -57,7 +54,7 @@ export class HeadlessChromiumDriverFactory { }); } - type = 'chromium'; + type = BROWSER_TYPE; test(logger: Logger) { const chromiumArgs = args({ @@ -153,7 +150,7 @@ export class HeadlessChromiumDriverFactory { // HeadlessChromiumDriver: object to "drive" a browser page const driver = new HeadlessChromiumDriver(page, { - inspect: this.browserConfig.inspect, + inspect: !!this.browserConfig.inspect, networkPolicy: this.captureConfig.networkPolicy, }); diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts index d32338ae3e311..5f89662c94da2 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BrowserConfig, CaptureConfig } from '../../../types'; +import { CaptureConfig } from '../../../server/types'; import { LevelLogger } from '../../lib'; import { HeadlessChromiumDriverFactory } from './driver_factory'; @@ -13,8 +13,7 @@ export { paths } from './paths'; export async function createDriverFactory( binaryPath: string, logger: LevelLogger, - browserConfig: BrowserConfig, captureConfig: CaptureConfig ): Promise { - return new HeadlessChromiumDriverFactory(binaryPath, logger, browserConfig, captureConfig); + return new HeadlessChromiumDriverFactory(binaryPath, logger, captureConfig); } diff --git a/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts b/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts index 49c6222c9f276..af3b86919dc50 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts @@ -4,24 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Logger } from '../../types'; +import { ReportingConfig } from '../types'; +import { HeadlessChromiumDriverFactory } from './chromium/driver_factory'; import { ensureBrowserDownloaded } from './download'; -import { installBrowser } from './install'; -import { ServerFacade, CaptureConfig, Logger } from '../../types'; -import { BROWSER_TYPE } from '../../common/constants'; import { chromium } from './index'; -import { HeadlessChromiumDriverFactory } from './chromium/driver_factory'; +import { installBrowser } from './install'; export async function createBrowserDriverFactory( - server: ServerFacade, + config: ReportingConfig, logger: Logger ): Promise { - const config = server.config(); - - const dataDir: string = config.get('path.data'); - const captureConfig: CaptureConfig = config.get('xpack.reporting.capture'); - const browserType = captureConfig.browser.type; + const captureConfig = config.get('capture'); + const browserConfig = captureConfig.browser.chromium; const browserAutoDownload = captureConfig.browser.autoDownload; - const browserConfig = captureConfig.browser[BROWSER_TYPE]; + const browserType = captureConfig.browser.type; + const dataDir = config.kbnConfig.get('path', 'data'); if (browserConfig.disableSandbox) { logger.warning(`Enabling the Chromium sandbox provides an additional layer of protection.`); @@ -32,7 +30,7 @@ export async function createBrowserDriverFactory( try { const { binaryPath } = await installBrowser(logger, chromium, dataDir); - return chromium.createDriverFactory(binaryPath, logger, browserConfig, captureConfig); + return chromium.createDriverFactory(binaryPath, logger, captureConfig); } catch (error) { if (error.cause && ['EACCES', 'EEXIST'].includes(error.cause.code)) { logger.error( diff --git a/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts b/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts index 73186966e3d2f..3697c4b86ce3c 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts @@ -4,16 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { resolve as resolvePath } from 'path'; import { existsSync } from 'fs'; - +import { resolve as resolvePath } from 'path'; +import { BROWSER_TYPE } from '../../../common/constants'; import { chromium } from '../index'; -import { BrowserDownload, BrowserType } from '../types'; - +import { BrowserDownload } from '../types'; import { md5 } from './checksum'; -import { asyncMap } from './util'; -import { download } from './download'; import { clean } from './clean'; +import { download } from './download'; +import { asyncMap } from './util'; /** * Check for the downloaded archive of each requested browser type and @@ -21,7 +20,7 @@ import { clean } from './clean'; * @param {String} browserType * @return {Promise} */ -export async function ensureBrowserDownloaded(browserType: BrowserType) { +export async function ensureBrowserDownloaded(browserType = BROWSER_TYPE) { await ensureDownloaded([chromium]); } diff --git a/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts b/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts index b36345c08bfee..9714c5965a5db 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts @@ -6,12 +6,7 @@ import * as _ from 'lodash'; import { parse } from 'url'; - -interface FirewallRule { - allow: boolean; - host?: string; - protocol?: string; -} +import { NetworkPolicyRule } from '../../types'; const isHostMatch = (actualHost: string, ruleHost: string) => { const hostParts = actualHost.split('.').reverse(); @@ -20,7 +15,7 @@ const isHostMatch = (actualHost: string, ruleHost: string) => { return _.every(ruleParts, (part, idx) => part === hostParts[idx]); }; -export const allowRequest = (url: string, rules: FirewallRule[]) => { +export const allowRequest = (url: string, rules: NetworkPolicyRule[]) => { const parsed = parse(url); if (!rules.length) { diff --git a/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts b/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts index 0c480fc82752b..f096073ec2f5f 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export type BrowserType = 'chromium'; - export interface BrowserDownload { paths: { archivesPath: string; diff --git a/x-pack/legacy/plugins/reporting/server/config/config.js b/x-pack/legacy/plugins/reporting/server/config/config.js deleted file mode 100644 index 08e4db464b003..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/config/config.js +++ /dev/null @@ -1,21 +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 { cpus } from 'os'; - -const defaultCPUCount = 2; - -function cpuCount() { - try { - return cpus().length; - } catch (e) { - return defaultCPUCount; - } -} - -export const config = { - concurrency: cpuCount(), -}; diff --git a/x-pack/legacy/plugins/reporting/server/core.ts b/x-pack/legacy/plugins/reporting/server/core.ts index 4506d41e4f5c3..c233a63833950 100644 --- a/x-pack/legacy/plugins/reporting/server/core.ts +++ b/x-pack/legacy/plugins/reporting/server/core.ts @@ -7,12 +7,14 @@ import * as Rx from 'rxjs'; import { first, mapTo } from 'rxjs/operators'; import { + ElasticsearchServiceSetup, IUiSettingsClient, KibanaRequest, SavedObjectsClient, SavedObjectsServiceStart, UiSettingsServiceStart, } from 'src/core/server'; +import { ConfigType as ReportingConfigType } from '../../../../plugins/reporting/server'; // @ts-ignore no module definition import { mirrorPluginStatus } from '../../../server/lib/mirror_plugin_status'; import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; @@ -25,14 +27,63 @@ import { ReportingSetupDeps } from './types'; interface ReportingInternalSetup { browserDriverFactory: HeadlessChromiumDriverFactory; + config: ReportingConfig; + elasticsearch: ElasticsearchServiceSetup; } interface ReportingInternalStart { + enqueueJob: EnqueueJobFn; + esqueue: ESQueueInstance; savedObjects: SavedObjectsServiceStart; uiSettings: UiSettingsServiceStart; - esqueue: ESQueueInstance; - enqueueJob: EnqueueJobFn; } +// make config.get() aware of the value type it returns +interface Config { + get(key1: Key1): BaseType[Key1]; + get( + key1: Key1, + key2: Key2 + ): BaseType[Key1][Key2]; + get< + Key1 extends keyof BaseType, + Key2 extends keyof BaseType[Key1], + Key3 extends keyof BaseType[Key1][Key2] + >( + key1: Key1, + key2: Key2, + key3: Key3 + ): BaseType[Key1][Key2][Key3]; + get< + Key1 extends keyof BaseType, + Key2 extends keyof BaseType[Key1], + Key3 extends keyof BaseType[Key1][Key2], + Key4 extends keyof BaseType[Key1][Key2][Key3] + >( + key1: Key1, + key2: Key2, + key3: Key3, + key4: Key4 + ): BaseType[Key1][Key2][Key3][Key4]; +} + +interface KbnServerConfigType { + path: { data: string }; + server: { + basePath: string; + host: string; + name: string; + port: number; + protocol: string; + uuid: string; + }; +} + +export interface ReportingConfig extends Config { + kbnConfig: Config; +} + +export { ReportingConfigType }; + export class ReportingCore { private pluginSetupDeps?: ReportingInternalSetup; private pluginStartDeps?: ReportingInternalStart; @@ -45,6 +96,7 @@ export class ReportingCore { legacySetup( xpackMainPlugin: XPackMainPlugin, reporting: ReportingPluginSpecOptions, + config: ReportingConfig, __LEGACY: ServerFacade, plugins: ReportingSetupDeps ) { @@ -56,7 +108,7 @@ export class ReportingCore { xpackMainPlugin.info.feature(PLUGIN_ID).registerLicenseCheckResultsGenerator(checkLicense); }); // Reporting routes - registerRoutes(this, __LEGACY, plugins, this.logger); + registerRoutes(this, config, __LEGACY, plugins, this.logger); } public pluginSetup(reportingSetupDeps: ReportingInternalSetup) { @@ -90,23 +142,31 @@ export class ReportingCore { return (await this.getPluginSetupDeps()).browserDriverFactory; } + public async getConfig(): Promise { + return (await this.getPluginSetupDeps()).config; + } + /* - * Kibana core module dependencies + * Outside dependencies */ - private async getPluginSetupDeps() { + private async getPluginSetupDeps(): Promise { if (this.pluginSetupDeps) { return this.pluginSetupDeps; } return await this.pluginSetup$.pipe(first()).toPromise(); } - private async getPluginStartDeps() { + private async getPluginStartDeps(): Promise { if (this.pluginStartDeps) { return this.pluginStartDeps; } return await this.pluginStart$.pipe(first()).toPromise(); } + public async getElasticsearchService(): Promise { + return (await this.getPluginSetupDeps()).elasticsearch; + } + public async getSavedObjectsClient(fakeRequest: KibanaRequest): Promise { const { savedObjects } = await this.getPluginStartDeps(); return savedObjects.getScopedClient(fakeRequest) as SavedObjectsClient; diff --git a/x-pack/legacy/plugins/reporting/server/index.ts b/x-pack/legacy/plugins/reporting/server/index.ts index 24e2a954415d9..efcfd6b7f783d 100644 --- a/x-pack/legacy/plugins/reporting/server/index.ts +++ b/x-pack/legacy/plugins/reporting/server/index.ts @@ -11,5 +11,5 @@ export const plugin = (context: PluginInitializerContext) => { return new Plugin(context); }; -export { ReportingCore } from './core'; export { ReportingPlugin } from './plugin'; +export { ReportingConfig, ReportingCore } from './core'; diff --git a/x-pack/legacy/plugins/reporting/server/legacy.ts b/x-pack/legacy/plugins/reporting/server/legacy.ts index 336ff5f4d2ee7..29e5af529767e 100644 --- a/x-pack/legacy/plugins/reporting/server/legacy.ts +++ b/x-pack/legacy/plugins/reporting/server/legacy.ts @@ -4,35 +4,75 @@ * you may not use this file except in compliance with the Elastic License. */ import { Legacy } from 'kibana'; -import { PluginInitializerContext } from 'src/core/server'; +import { get } from 'lodash'; +import { take } from 'rxjs/operators'; +import { CoreSetup, PluginInitializerContext } from 'src/core/server'; +import { ConfigType, PluginsSetup } from '../../../../plugins/reporting/server'; import { SecurityPluginSetup } from '../../../../plugins/security/server'; import { ReportingPluginSpecOptions } from '../types'; import { plugin } from './index'; -import { LegacySetup, ReportingStartDeps } from './types'; +import { LegacySetup, ReportingConfig, ReportingStartDeps } from './types'; const buildLegacyDependencies = ( + coreSetup: CoreSetup, server: Legacy.Server, reportingPlugin: ReportingPluginSpecOptions -): LegacySetup => ({ - config: server.config, - info: server.info, - route: server.route.bind(server), - plugins: { - elasticsearch: server.plugins.elasticsearch, - xpack_main: server.plugins.xpack_main, - reporting: reportingPlugin, - }, -}); +): LegacySetup => { + return { + route: server.route.bind(server), + plugins: { + xpack_main: server.plugins.xpack_main, + reporting: reportingPlugin, + }, + }; +}; + +const buildConfig = ( + coreSetup: CoreSetup, + server: Legacy.Server, + reportingConfig: ConfigType +): ReportingConfig => { + const config = server.config(); + const { http } = coreSetup; + const serverInfo = http.getServerInfo(); + + const kbnConfig = { + path: { + data: config.get('path.data'), // FIXME: get from the real PluginInitializerContext + }, + server: { + basePath: coreSetup.http.basePath.serverBasePath, + host: serverInfo.host, + name: serverInfo.name, + port: serverInfo.port, + uuid: coreSetup.uuid.getInstanceUuid(), + protocol: serverInfo.protocol, + }, + }; + + // spreading arguments as an array allows the return type to be known by the compiler + return { + get: (...keys: string[]) => get(reportingConfig, keys.join('.'), null), + kbnConfig: { + get: (...keys: string[]) => get(kbnConfig, keys.join('.'), null), + }, + }; +}; export const legacyInit = async ( server: Legacy.Server, - reportingPlugin: ReportingPluginSpecOptions + reportingLegacyPlugin: ReportingPluginSpecOptions ) => { - const coreSetup = server.newPlatform.setup.core; - const pluginInstance = plugin(server.newPlatform.coreContext as PluginInitializerContext); + const { core: coreSetup } = server.newPlatform.setup; + const { config$ } = (server.newPlatform.setup.plugins.reporting as PluginsSetup).__legacy; + const reportingConfig = await config$.pipe(take(1)).toPromise(); + const reporting = { config: buildConfig(coreSetup, server, reportingConfig) }; + + const __LEGACY = buildLegacyDependencies(coreSetup, server, reportingLegacyPlugin); - const __LEGACY = buildLegacyDependencies(server, reportingPlugin); + const pluginInstance = plugin(server.newPlatform.coreContext as PluginInitializerContext); // NOTE: mocked-out PluginInitializerContext await pluginInstance.setup(coreSetup, { + reporting, elasticsearch: coreSetup.elasticsearch, security: server.newPlatform.setup.plugins.security as SecurityPluginSetup, usageCollection: server.newPlatform.setup.plugins.usageCollection, @@ -42,7 +82,6 @@ export const legacyInit = async ( // Schedule to call the "start" hook only after start dependencies are ready coreSetup.getStartServices().then(([core, plugins]) => pluginInstance.start(core, { - elasticsearch: coreSetup.elasticsearch, data: (plugins as ReportingStartDeps).data, __LEGACY, }) diff --git a/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts b/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts index d593e4625cdf4..a05205526dd3e 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts @@ -4,22 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchServiceSetup } from 'kibana/server'; -import { ESQueueInstance, ServerFacade, QueueConfig, Logger } from '../../types'; +import { ESQueueInstance, Logger } from '../../types'; import { ReportingCore } from '../core'; +import { createTaggedLogger } from './create_tagged_logger'; // TODO remove createTaggedLogger once esqueue is removed +import { createWorkerFactory } from './create_worker'; // @ts-ignore import { Esqueue } from './esqueue'; -import { createWorkerFactory } from './create_worker'; -import { createTaggedLogger } from './create_tagged_logger'; // TODO remove createTaggedLogger once esqueue is removed export async function createQueueFactory( reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, logger: Logger ): Promise { - const queueConfig: QueueConfig = server.config().get('xpack.reporting.queue'); - const index = server.config().get('xpack.reporting.index'); + const [config, elasticsearch] = await Promise.all([ + reporting.getConfig(), + reporting.getElasticsearchService(), + ]); + + const queueConfig = config.get('queue'); + const index = config.get('index'); const queueOptions = { interval: queueConfig.indexInterval, @@ -33,7 +35,7 @@ export async function createQueueFactory( if (queueConfig.pollEnabled) { // create workers to poll the index for idle jobs waiting to be claimed and executed - const createWorker = createWorkerFactory(reporting, server, elasticsearch, logger); + const createWorker = await createWorkerFactory(reporting, config, logger); await createWorker(queue); } else { logger.info( diff --git a/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts b/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts index d4d913243e18d..01a937a49873a 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchServiceSetup } from 'kibana/server'; import * as sinon from 'sinon'; -import { ReportingCore } from '../../server'; +import { ReportingConfig, ReportingCore } from '../../server/types'; import { createMockReportingCore } from '../../test_helpers'; -import { ServerFacade } from '../../types'; import { createWorkerFactory } from './create_worker'; // @ts-ignore import { Esqueue } from './esqueue'; @@ -17,21 +15,15 @@ import { ClientMock } from './esqueue/__tests__/fixtures/legacy_elasticsearch'; import { ExportTypesRegistry } from './export_types_registry'; const configGetStub = sinon.stub(); -configGetStub.withArgs('xpack.reporting.queue').returns({ +configGetStub.withArgs('queue').returns({ pollInterval: 3300, pollIntervalErrorMultiplier: 10, }); -configGetStub.withArgs('server.name').returns('test-server-123'); -configGetStub.withArgs('server.uuid').returns('g9ymiujthvy6v8yrh7567g6fwzgzftzfr'); +configGetStub.withArgs('server', 'name').returns('test-server-123'); +configGetStub.withArgs('server', 'uuid').returns('g9ymiujthvy6v8yrh7567g6fwzgzftzfr'); const executeJobFactoryStub = sinon.stub(); - -const getMockServer = (): ServerFacade => { - return ({ - config: () => ({ get: configGetStub }), - } as unknown) as ServerFacade; -}; -const getMockLogger = jest.fn(); +const getMockLogger = sinon.stub(); const getMockExportTypesRegistry = ( exportTypes: any[] = [{ executeJobFactory: executeJobFactoryStub }] @@ -41,25 +33,23 @@ const getMockExportTypesRegistry = ( } as ExportTypesRegistry); describe('Create Worker', () => { + let mockReporting: ReportingCore; + let mockConfig: ReportingConfig; let queue: Esqueue; let client: ClientMock; - let mockReporting: ReportingCore; beforeEach(async () => { mockReporting = await createMockReportingCore(); + mockReporting.getExportTypesRegistry = () => getMockExportTypesRegistry(); + mockConfig = { get: configGetStub, kbnConfig: { get: configGetStub } }; + mockReporting.getConfig = () => Promise.resolve(mockConfig); client = new ClientMock(); queue = new Esqueue('reporting-queue', { client }); executeJobFactoryStub.reset(); }); test('Creates a single Esqueue worker for Reporting', async () => { - mockReporting.getExportTypesRegistry = () => getMockExportTypesRegistry(); - const createWorker = createWorkerFactory( - mockReporting, - getMockServer(), - {} as ElasticsearchServiceSetup, - getMockLogger() - ); + const createWorker = await createWorkerFactory(mockReporting, mockConfig, getMockLogger()); const registerWorkerSpy = sinon.spy(queue, 'registerWorker'); await createWorker(queue); @@ -91,12 +81,7 @@ Object { { executeJobFactory: executeJobFactoryStub }, ]); mockReporting.getExportTypesRegistry = () => exportTypesRegistry; - const createWorker = createWorkerFactory( - mockReporting, - getMockServer(), - {} as ElasticsearchServiceSetup, - getMockLogger() - ); + const createWorker = await createWorkerFactory(mockReporting, mockConfig, getMockLogger()); const registerWorkerSpy = sinon.spy(queue, 'registerWorker'); await createWorker(queue); diff --git a/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts b/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts index 3567712367608..e9d0acf29c721 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchServiceSetup } from 'kibana/server'; import { CancellationToken } from '../../common/cancellation_token'; import { PLUGIN_ID } from '../../common/constants'; +import { ReportingConfig } from '../../server/types'; import { ESQueueInstance, ESQueueWorkerExecuteFn, @@ -15,25 +15,22 @@ import { JobDocPayload, JobSource, Logger, - QueueConfig, RequestFacade, - ServerFacade, } from '../../types'; import { ReportingCore } from '../core'; // @ts-ignore untyped dependency import { events as esqueueEvents } from './esqueue'; -export function createWorkerFactory( +export async function createWorkerFactory( reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, + config: ReportingConfig, logger: Logger ) { type JobDocPayloadType = JobDocPayload; - const config = server.config(); - const queueConfig: QueueConfig = config.get('xpack.reporting.queue'); - const kibanaName: string = config.get('server.name'); - const kibanaId: string = config.get('server.uuid'); + + const queueConfig = config.get('queue'); + const kibanaName = config.kbnConfig.get('server', 'name'); + const kibanaId = config.kbnConfig.get('server', 'uuid'); // Once more document types are added, this will need to be passed in return async function createWorker(queue: ESQueueInstance) { @@ -47,12 +44,7 @@ export function createWorkerFactory( ExportTypeDefinition >) { // TODO: the executeJobFn should be unwrapped in the register method of the export types registry - const jobExecutor = await exportType.executeJobFactory( - reporting, - server, - elasticsearch, - logger - ); + const jobExecutor = await exportType.executeJobFactory(reporting, logger); jobExecutors.set(exportType.jobType, jobExecutor); } diff --git a/x-pack/legacy/plugins/reporting/server/lib/crypto.ts b/x-pack/legacy/plugins/reporting/server/lib/crypto.ts index dbc01fc947f8b..97876529ecfa7 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/crypto.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/crypto.ts @@ -5,12 +5,7 @@ */ import nodeCrypto from '@elastic/node-crypto'; -import { oncePerServer } from './once_per_server'; -import { ServerFacade } from '../../types'; -function cryptoFn(server: ServerFacade) { - const encryptionKey = server.config().get('xpack.reporting.encryptionKey'); +export function cryptoFactory(encryptionKey: string | undefined) { return nodeCrypto({ encryptionKey }); } - -export const cryptoFactory = oncePerServer(cryptoFn); diff --git a/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts b/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts index c215bdc398904..bc4754b02ed57 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts @@ -5,22 +5,18 @@ */ import { get } from 'lodash'; -import { ElasticsearchServiceSetup } from 'kibana/server'; -// @ts-ignore -import { events as esqueueEvents } from './esqueue'; import { + ConditionalHeaders, EnqueueJobFn, ESQueueCreateJobFn, ImmediateCreateJobFn, Job, - ServerFacade, - RequestFacade, Logger, - CaptureConfig, - QueueConfig, - ConditionalHeaders, + RequestFacade, } from '../../types'; import { ReportingCore } from '../core'; +// @ts-ignore +import { events as esqueueEvents } from './esqueue'; interface ConfirmedJob { id: string; @@ -29,18 +25,16 @@ interface ConfirmedJob { _primary_term: number; } -export function enqueueJobFactory( +export async function enqueueJobFactory( reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, parentLogger: Logger -): EnqueueJobFn { +): Promise { + const config = await reporting.getConfig(); const logger = parentLogger.clone(['queue-job']); - const config = server.config(); - const captureConfig: CaptureConfig = config.get('xpack.reporting.capture'); + const captureConfig = config.get('capture'); const browserType = captureConfig.browser.type; const maxAttempts = captureConfig.maxAttempts; - const queueConfig: QueueConfig = config.get('xpack.reporting.queue'); + const queueConfig = config.get('queue'); return async function enqueueJob( exportTypeId: string, @@ -59,12 +53,7 @@ export function enqueueJobFactory( } // TODO: the createJobFn should be unwrapped in the register method of the export types registry - const createJob = exportType.createJobFactory( - reporting, - server, - elasticsearch, - logger - ) as CreateJobFn; + const createJob = (await exportType.createJobFactory(reporting, logger)) as CreateJobFn; const payload = await createJob(jobParams, headers, request); const options = { diff --git a/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js b/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js index 6cdbe8f968f75..8e4047e2f22e5 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js +++ b/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js @@ -8,6 +8,7 @@ import moment from 'moment'; export const intervals = ['year', 'month', 'week', 'day', 'hour', 'minute']; +// TODO: remove this helper by using `schema.duration` objects in the reporting config schema export function indexTimestamp(intervalStr, separator = '-') { if (separator.match(/[a-z]/i)) throw new Error('Interval separator can not be a letter'); diff --git a/x-pack/legacy/plugins/reporting/server/lib/get_user.ts b/x-pack/legacy/plugins/reporting/server/lib/get_user.ts index 49d5c568c3981..5e73fe77ecb79 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/get_user.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/get_user.ts @@ -6,10 +6,10 @@ import { Legacy } from 'kibana'; import { KibanaRequest } from '../../../../../../src/core/server'; -import { ServerFacade } from '../../types'; +import { Logger } from '../../types'; import { ReportingSetupDeps } from '../types'; -export function getUserFactory(server: ServerFacade, security: ReportingSetupDeps['security']) { +export function getUserFactory(security: ReportingSetupDeps['security'], logger: Logger) { /* * Legacy.Request because this is called from routing middleware */ diff --git a/x-pack/legacy/plugins/reporting/server/lib/index.ts b/x-pack/legacy/plugins/reporting/server/lib/index.ts index 0a2db749cb954..f5ccbe493a91f 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/index.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/index.ts @@ -4,11 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -export { getExportTypesRegistry } from './export_types_registry'; export { checkLicenseFactory } from './check_license'; -export { LevelLogger } from './level_logger'; -export { cryptoFactory } from './crypto'; -export { oncePerServer } from './once_per_server'; -export { runValidations } from './validate'; export { createQueueFactory } from './create_queue'; +export { cryptoFactory } from './crypto'; export { enqueueJobFactory } from './enqueue_job'; +export { getExportTypesRegistry } from './export_types_registry'; +export { LevelLogger } from './level_logger'; +export { runValidations } from './validate'; diff --git a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts index c01e6377b039e..0affc111c1368 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts @@ -9,7 +9,8 @@ import Boom from 'boom'; import { errors as elasticsearchErrors } from 'elasticsearch'; import { ElasticsearchServiceSetup } from 'kibana/server'; import { get } from 'lodash'; -import { JobSource, ServerFacade } from '../../types'; +import { JobSource } from '../../types'; +import { ReportingConfig } from '../types'; const esErrors = elasticsearchErrors as Record; const defaultSize = 10; @@ -39,8 +40,11 @@ interface CountAggResult { count: number; } -export function jobsQueryFactory(server: ServerFacade, elasticsearch: ElasticsearchServiceSetup) { - const index = server.config().get('xpack.reporting.index'); +export function jobsQueryFactory( + config: ReportingConfig, + elasticsearch: ElasticsearchServiceSetup +) { + const index = config.get('index'); const { callAsInternalUser } = elasticsearch.adminClient; function getUsername(user: any) { diff --git a/x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts b/x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts deleted file mode 100644 index ae3636079a9bb..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts +++ /dev/null @@ -1,43 +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 { memoize, MemoizedFunction } from 'lodash'; -import { ServerFacade } from '../../types'; - -type ServerFn = (server: ServerFacade) => any; -type Memo = ((server: ServerFacade) => any) & MemoizedFunction; - -/** - * allow this function to be called multiple times, but - * ensure that it only received one argument, the server, - * and cache the return value so that subsequent calls get - * the exact same value. - * - * This is intended to be used by service factories like getObjectQueueFactory - * - * @param {Function} fn - the factory function - * @return {any} - */ -export function oncePerServer(fn: ServerFn) { - const memoized: Memo = memoize(function(server: ServerFacade) { - if (arguments.length !== 1) { - throw new TypeError('This function expects to be called with a single argument'); - } - - // @ts-ignore - return fn.call(this, server); - }); - - // @ts-ignore - // Type 'WeakMap' is not assignable to type 'MapCache - - // use a weak map a the cache so that: - // 1. return values mapped to the actual server instance - // 2. return value lifecycle matches that of the server - memoized.cache = new WeakMap(); - - return memoized; -} diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js deleted file mode 100644 index 10980f702d849..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js +++ /dev/null @@ -1,34 +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 expect from '@kbn/expect'; -import sinon from 'sinon'; -import { validateEncryptionKey } from '../validate_encryption_key'; - -describe('Reporting: Validate config', () => { - const logger = { - warning: sinon.spy(), - }; - - beforeEach(() => { - logger.warning.resetHistory(); - }); - - [undefined, null].forEach(value => { - it(`should log a warning and set xpack.reporting.encryptionKey if encryptionKey is ${value}`, () => { - const config = { - get: sinon.stub().returns(value), - set: sinon.stub(), - }; - - expect(() => validateEncryptionKey({ config: () => config }, logger)).not.to.throwError(); - - sinon.assert.calledWith(config.set, 'xpack.reporting.encryptionKey'); - sinon.assert.calledWithMatch(logger.warning, /Generating a random key/); - sinon.assert.calledWithMatch(logger.warning, /please set xpack.reporting.encryptionKey/); - }); - }); -}); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts deleted file mode 100644 index 04f998fd3e5a5..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts +++ /dev/null @@ -1,30 +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 expect from '@kbn/expect'; -import sinon from 'sinon'; -import { ServerFacade } from '../../../../types'; -import { validateServerHost } from '../validate_server_host'; - -const configKey = 'xpack.reporting.kibanaServer.hostname'; - -describe('Reporting: Validate server host setting', () => { - it(`should log a warning and set ${configKey} if server.host is "0"`, () => { - const getStub = sinon.stub(); - getStub.withArgs('server.host').returns('0'); - getStub.withArgs(configKey).returns(undefined); - const config = { - get: getStub, - set: sinon.stub(), - }; - - expect(() => - validateServerHost(({ config: () => config } as unknown) as ServerFacade) - ).to.throwError(); - - sinon.assert.calledWith(config.set, configKey); - }); -}); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts index 0fdbd858b8e3c..85d9f727d7fa7 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts @@ -6,25 +6,22 @@ import { i18n } from '@kbn/i18n'; import { ElasticsearchServiceSetup } from 'kibana/server'; -import { Logger, ServerFacade } from '../../../types'; +import { Logger } from '../../../types'; import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_factory'; +import { ReportingConfig } from '../../types'; import { validateBrowser } from './validate_browser'; -import { validateEncryptionKey } from './validate_encryption_key'; import { validateMaxContentLength } from './validate_max_content_length'; -import { validateServerHost } from './validate_server_host'; export async function runValidations( - server: ServerFacade, + config: ReportingConfig, elasticsearch: ElasticsearchServiceSetup, browserFactory: HeadlessChromiumDriverFactory, logger: Logger ) { try { await Promise.all([ - validateBrowser(server, browserFactory, logger), - validateEncryptionKey(server, logger), - validateMaxContentLength(server, elasticsearch, logger), - validateServerHost(server), + validateBrowser(browserFactory, logger), + validateMaxContentLength(config, elasticsearch, logger), ]); logger.debug( i18n.translate('xpack.reporting.selfCheck.ok', { diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts index 89c49123e85bf..d6512d5eb718b 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts @@ -3,9 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { Browser } from 'puppeteer'; import { BROWSER_TYPE } from '../../../common/constants'; -import { ServerFacade, Logger } from '../../../types'; +import { Logger } from '../../../types'; import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_factory'; /* @@ -13,7 +14,6 @@ import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_fa * to the locally running Kibana instance. */ export const validateBrowser = async ( - server: ServerFacade, browserFactory: HeadlessChromiumDriverFactory, logger: Logger ) => { diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts deleted file mode 100644 index e0af94cbdc29c..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts +++ /dev/null @@ -1,31 +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 { i18n } from '@kbn/i18n'; -import crypto from 'crypto'; -import { ServerFacade, Logger } from '../../../types'; - -export function validateEncryptionKey(serverFacade: ServerFacade, logger: Logger) { - const config = serverFacade.config(); - - const encryptionKey = config.get('xpack.reporting.encryptionKey'); - if (encryptionKey == null) { - // TODO this should simply throw an error and let the handler conver it to a warning mesasge. See validateServerHost. - logger.warning( - i18n.translate('xpack.reporting.selfCheckEncryptionKey.warning', { - defaultMessage: - `Generating a random key for {setting}. To prevent pending reports ` + - `from failing on restart, please set {setting} in kibana.yml`, - values: { - setting: 'xpack.reporting.encryptionKey', - }, - }) - ); - - // @ts-ignore: No set() method on KibanaConfig, just get() and has() - config.set('xpack.reporting.encryptionKey', crypto.randomBytes(16).toString('hex')); // update config in memory to contain a usable encryption key - } -} diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js index 942dcaf842696..2551fd48b91f3 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js @@ -32,11 +32,7 @@ describe('Reporting: Validate Max Content Length', () => { }); it('should log warning messages when reporting has a higher max-size than elasticsearch', async () => { - const server = { - config: () => ({ - get: sinon.stub().returns(FIVE_HUNDRED_MEGABYTES), - }), - }; + const config = { get: sinon.stub().returns(FIVE_HUNDRED_MEGABYTES) }; const elasticsearch = { dataClient: { callAsInternalUser: () => ({ @@ -49,7 +45,7 @@ describe('Reporting: Validate Max Content Length', () => { }, }; - await validateMaxContentLength(server, elasticsearch, logger); + await validateMaxContentLength(config, elasticsearch, logger); sinon.assert.calledWithMatch( logger.warning, @@ -70,14 +66,10 @@ describe('Reporting: Validate Max Content Length', () => { }); it('should do nothing when reporting has the same max-size as elasticsearch', async () => { - const server = { - config: () => ({ - get: sinon.stub().returns(ONE_HUNDRED_MEGABYTES), - }), - }; + const config = { get: sinon.stub().returns(ONE_HUNDRED_MEGABYTES) }; expect( - async () => await validateMaxContentLength(server, elasticsearch, logger.warning) + async () => await validateMaxContentLength(config, elasticsearch, logger.warning) ).not.toThrow(); sinon.assert.notCalled(logger.warning); }); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts index ce4a5b93e7431..a20905ba093d4 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts @@ -7,17 +7,17 @@ import numeral from '@elastic/numeral'; import { ElasticsearchServiceSetup } from 'kibana/server'; import { defaults, get } from 'lodash'; -import { Logger, ServerFacade } from '../../../types'; +import { Logger } from '../../../types'; +import { ReportingConfig } from '../../types'; -const KIBANA_MAX_SIZE_BYTES_PATH = 'xpack.reporting.csv.maxSizeBytes'; +const KIBANA_MAX_SIZE_BYTES_PATH = 'csv.maxSizeBytes'; const ES_MAX_SIZE_BYTES_PATH = 'http.max_content_length'; export async function validateMaxContentLength( - server: ServerFacade, + config: ReportingConfig, elasticsearch: ElasticsearchServiceSetup, logger: Logger ) { - const config = server.config(); const { callAsInternalUser } = elasticsearch.dataClient; const elasticClusterSettingsResponse = await callAsInternalUser('cluster.getSettings', { @@ -28,13 +28,13 @@ export async function validateMaxContentLength( const elasticSearchMaxContent = get(elasticClusterSettings, 'http.max_content_length', '100mb'); const elasticSearchMaxContentBytes = numeral().unformat(elasticSearchMaxContent.toUpperCase()); - const kibanaMaxContentBytes: number = config.get(KIBANA_MAX_SIZE_BYTES_PATH); + const kibanaMaxContentBytes = config.get('csv', 'maxSizeBytes'); if (kibanaMaxContentBytes > elasticSearchMaxContentBytes) { // TODO this should simply throw an error and let the handler conver it to a warning mesasge. See validateServerHost. logger.warning( - `${KIBANA_MAX_SIZE_BYTES_PATH} (${kibanaMaxContentBytes}) is higher than ElasticSearch's ${ES_MAX_SIZE_BYTES_PATH} (${elasticSearchMaxContentBytes}). ` + - `Please set ${ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your ${KIBANA_MAX_SIZE_BYTES_PATH} in Kibana to avoid this warning.` + `xpack.reporting.${KIBANA_MAX_SIZE_BYTES_PATH} (${kibanaMaxContentBytes}) is higher than ElasticSearch's ${ES_MAX_SIZE_BYTES_PATH} (${elasticSearchMaxContentBytes}). ` + + `Please set ${ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your xpack.reporting.${KIBANA_MAX_SIZE_BYTES_PATH} in Kibana to avoid this warning.` ); } } diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts deleted file mode 100644 index f4f4d61246b6a..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts +++ /dev/null @@ -1,27 +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 { ServerFacade } from '../../../types'; - -const configKey = 'xpack.reporting.kibanaServer.hostname'; - -export function validateServerHost(serverFacade: ServerFacade) { - const config = serverFacade.config(); - - const serverHost = config.get('server.host'); - const reportingKibanaHostName = config.get(configKey); - - if (!reportingKibanaHostName && serverHost === '0') { - // @ts-ignore: No set() method on KibanaConfig, just get() and has() - config.set(configKey, '0.0.0.0'); // update config in memory to allow Reporting to work - - throw new Error( - `Found 'server.host: "0"' in settings. This is incompatible with Reporting. ` + - `To enable Reporting to work, '${configKey}: 0.0.0.0' is being automatically to the configuration. ` + - `You can change to 'server.host: 0.0.0.0' or add '${configKey}: 0.0.0.0' in kibana.yml to prevent this message.` - ); - } -} diff --git a/x-pack/legacy/plugins/reporting/server/plugin.ts b/x-pack/legacy/plugins/reporting/server/plugin.ts index 4f24cc16b2277..1d7cc075b690d 100644 --- a/x-pack/legacy/plugins/reporting/server/plugin.ts +++ b/x-pack/legacy/plugins/reporting/server/plugin.ts @@ -12,8 +12,6 @@ import { createQueueFactory, enqueueJobFactory, LevelLogger, runValidations } fr import { setFieldFormats } from './services'; import { ReportingSetup, ReportingSetupDeps, ReportingStart, ReportingStartDeps } from './types'; import { registerReportingUsageCollector } from './usage'; -// @ts-ignore no module definition -import { mirrorPluginStatus } from '../../../server/lib/mirror_plugin_status'; export class ReportingPlugin implements Plugin { @@ -26,29 +24,29 @@ export class ReportingPlugin } public async setup(core: CoreSetup, plugins: ReportingSetupDeps) { - const { elasticsearch, usageCollection, __LEGACY } = plugins; + const { reporting: reportingNewPlatform, elasticsearch, __LEGACY } = plugins; + const { config } = reportingNewPlatform; - const browserDriverFactory = await createBrowserDriverFactory(__LEGACY, this.logger); // required for validations :( - runValidations(__LEGACY, elasticsearch, browserDriverFactory, this.logger); // this must run early, as it sets up config defaults + const browserDriverFactory = await createBrowserDriverFactory(config, this.logger); // required for validations :( + runValidations(config, elasticsearch, browserDriverFactory, this.logger); // this must run early, as it sets up config defaults const { xpack_main: xpackMainLegacy, reporting: reportingLegacy } = __LEGACY.plugins; - this.reportingCore.legacySetup(xpackMainLegacy, reportingLegacy, __LEGACY, plugins); + this.reportingCore.legacySetup(xpackMainLegacy, reportingLegacy, config, __LEGACY, plugins); // Register a function with server to manage the collection of usage stats - registerReportingUsageCollector(this.reportingCore, __LEGACY, usageCollection); + registerReportingUsageCollector(this.reportingCore, config, plugins); // regsister setup internals - this.reportingCore.pluginSetup({ browserDriverFactory }); + this.reportingCore.pluginSetup({ browserDriverFactory, config, elasticsearch }); return {}; } public async start(core: CoreStart, plugins: ReportingStartDeps) { const { reportingCore, logger } = this; - const { elasticsearch, __LEGACY } = plugins; - const esqueue = await createQueueFactory(reportingCore, __LEGACY, elasticsearch, logger); - const enqueueJob = enqueueJobFactory(reportingCore, __LEGACY, elasticsearch, logger); + const esqueue = await createQueueFactory(reportingCore, logger); + const enqueueJob = await enqueueJobFactory(reportingCore, logger); this.reportingCore.pluginStart({ savedObjects: core.savedObjects, @@ -58,7 +56,9 @@ export class ReportingPlugin }); setFieldFormats(plugins.data.fieldFormats); - logConfiguration(__LEGACY, this.logger); + + const config = await reportingCore.getConfig(); + logConfiguration(config.get('capture'), this.logger); return {}; } diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts index 56622617586f7..dc58e97ff3e41 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts @@ -10,7 +10,7 @@ import { Legacy } from 'kibana'; import rison from 'rison-node'; import { API_BASE_URL } from '../../common/constants'; import { Logger, ReportingResponseToolkit, ServerFacade } from '../../types'; -import { ReportingSetupDeps } from '../types'; +import { ReportingConfig, ReportingSetupDeps } from '../types'; import { makeRequestFacade } from './lib/make_request_facade'; import { GetRouteConfigFactoryFn, @@ -22,6 +22,7 @@ import { HandlerErrorFunction, HandlerFunction } from './types'; const BASE_GENERATE = `${API_BASE_URL}/generate`; export function registerGenerateFromJobParams( + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, handler: HandlerFunction, @@ -30,7 +31,7 @@ export function registerGenerateFromJobParams( ) { const getRouteConfig = () => { const getOriginalRouteConfig: GetRouteConfigFactoryFn = getRouteConfigFactoryReportingPre( - server, + config, plugins, logger ); diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts index 415b6b7d64366..23ab7ee0d9e6b 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts @@ -9,7 +9,7 @@ import { get } from 'lodash'; import { API_BASE_GENERATE_V1, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../common/constants'; import { getJobParamsFromRequest } from '../../export_types/csv_from_savedobject/server/lib/get_job_params_from_request'; import { Logger, ReportingResponseToolkit, ServerFacade } from '../../types'; -import { ReportingSetupDeps } from '../types'; +import { ReportingConfig, ReportingSetupDeps } from '../types'; import { makeRequestFacade } from './lib/make_request_facade'; import { getRouteOptionsCsv } from './lib/route_config_factories'; import { HandlerErrorFunction, HandlerFunction, QueuedJobPayload } from './types'; @@ -24,13 +24,14 @@ import { HandlerErrorFunction, HandlerFunction, QueuedJobPayload } from './types * - local (transient) changes the user made to the saved object */ export function registerGenerateCsvFromSavedObject( + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, handleRoute: HandlerFunction, handleRouteError: HandlerErrorFunction, logger: Logger ) { - const routeOptions = getRouteOptionsCsv(server, plugins, logger); + const routeOptions = getRouteOptionsCsv(config, plugins, logger); server.route({ path: `${API_BASE_GENERATE_V1}/csv/saved-object/{savedObjectType}:{savedObjectId}`, diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts index 5d17fa2e82b8c..5bd07aa6049ed 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts @@ -16,7 +16,7 @@ import { ResponseFacade, ServerFacade, } from '../../types'; -import { ReportingSetupDeps, ReportingCore } from '../types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; import { makeRequestFacade } from './lib/make_request_facade'; import { getRouteOptionsCsv } from './lib/route_config_factories'; @@ -31,12 +31,12 @@ import { getRouteOptionsCsv } from './lib/route_config_factories'; */ export function registerGenerateCsvFromSavedObjectImmediate( reporting: ReportingCore, + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, parentLogger: Logger ) { - const routeOptions = getRouteOptionsCsv(server, plugins, parentLogger); - const { elasticsearch } = plugins; + const routeOptions = getRouteOptionsCsv(config, plugins, parentLogger); /* * CSV export with the `immediate` option does not queue a job with Reporting's ESQueue to run the job async. Instead, this does: @@ -52,14 +52,10 @@ export function registerGenerateCsvFromSavedObjectImmediate( const logger = parentLogger.clone(['savedobject-csv']); const jobParams = getJobParamsFromRequest(request, { isImmediate: true }); - /* TODO these functions should be made available in the export types registry: - * - * const { createJobFn, executeJobFn } = exportTypesRegistry.getById(CSV_FROM_SAVEDOBJECT_JOB_TYPE) - * - * Calling an execute job factory requires passing a browserDriverFactory option, so we should not call the factory from here - */ - const createJobFn = createJobFactory(reporting, server, elasticsearch, logger); - const executeJobFn = await executeJobFactory(reporting, server, elasticsearch, logger); + const [createJobFn, executeJobFn] = await Promise.all([ + createJobFactory(reporting, logger), + executeJobFactory(reporting, logger), + ]); const jobDocPayload: JobDocPayloadPanelCsv = await createJobFn( jobParams, request.headers, diff --git a/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts b/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts index 54d9671692c5d..44a98dac2d4a9 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts @@ -7,7 +7,7 @@ import Hapi from 'hapi'; import { createMockReportingCore } from '../../test_helpers'; import { Logger, ServerFacade } from '../../types'; -import { ReportingCore, ReportingSetupDeps } from '../../server/types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; jest.mock('./lib/authorized_user_pre_routing', () => ({ authorizedUserPreRoutingFactory: () => () => ({}), @@ -22,6 +22,8 @@ import { registerJobGenerationRoutes } from './generation'; let mockServer: Hapi.Server; let mockReportingPlugin: ReportingCore; +let mockReportingConfig: ReportingConfig; + const mockLogger = ({ error: jest.fn(), debug: jest.fn(), @@ -33,10 +35,12 @@ beforeEach(async () => { port: 8080, routes: { log: { collect: true } }, }); - mockServer.config = () => ({ get: jest.fn(), has: jest.fn() }); + mockReportingPlugin = await createMockReportingCore(); mockReportingPlugin.getEnqueueJob = async () => jest.fn().mockImplementation(() => ({ toJSON: () => '{ "job": "data" }' })); + + mockReportingConfig = { get: jest.fn(), kbnConfig: { get: jest.fn() } }; }); const mockPlugins = { @@ -54,6 +58,7 @@ const getErrorsFromRequest = (request: Hapi.Request) => { test(`returns 400 if there are no job params`, async () => { registerJobGenerationRoutes( mockReportingPlugin, + (mockReportingConfig as unknown) as ReportingConfig, (mockServer as unknown) as ServerFacade, (mockPlugins as unknown) as ReportingSetupDeps, mockLogger @@ -80,6 +85,7 @@ test(`returns 400 if there are no job params`, async () => { test(`returns 400 if job params is invalid`, async () => { registerJobGenerationRoutes( mockReportingPlugin, + (mockReportingConfig as unknown) as ReportingConfig, (mockServer as unknown) as ServerFacade, (mockPlugins as unknown) as ReportingSetupDeps, mockLogger @@ -114,6 +120,7 @@ test(`returns 500 if job handler throws an error`, async () => { registerJobGenerationRoutes( mockReportingPlugin, + (mockReportingConfig as unknown) as ReportingConfig, (mockServer as unknown) as ServerFacade, (mockPlugins as unknown) as ReportingSetupDeps, mockLogger diff --git a/x-pack/legacy/plugins/reporting/server/routes/generation.ts b/x-pack/legacy/plugins/reporting/server/routes/generation.ts index 096ba84b63d1a..0ac6a34dd75bb 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generation.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generation.ts @@ -9,7 +9,7 @@ import { errors as elasticsearchErrors } from 'elasticsearch'; import { Legacy } from 'kibana'; import { API_BASE_URL } from '../../common/constants'; import { Logger, ReportingResponseToolkit, ServerFacade } from '../../types'; -import { ReportingSetupDeps, ReportingCore } from '../types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; import { registerGenerateFromJobParams } from './generate_from_jobparams'; import { registerGenerateCsvFromSavedObject } from './generate_from_savedobject'; import { registerGenerateCsvFromSavedObjectImmediate } from './generate_from_savedobject_immediate'; @@ -19,12 +19,13 @@ const esErrors = elasticsearchErrors as Record; export function registerJobGenerationRoutes( reporting: ReportingCore, + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { - const config = server.config(); - const DOWNLOAD_BASE_URL = config.get('server.basePath') + `${API_BASE_URL}/jobs/download`; + const DOWNLOAD_BASE_URL = + `${config.kbnConfig.get('server', 'basePath')}` + `${API_BASE_URL}/jobs/download`; /* * Generates enqueued job details to use in responses @@ -66,11 +67,11 @@ export function registerJobGenerationRoutes( return err; } - registerGenerateFromJobParams(server, plugins, handler, handleError, logger); + registerGenerateFromJobParams(config, server, plugins, handler, handleError, logger); // Register beta panel-action download-related API's - if (config.get('xpack.reporting.csv.enablePanelActionDownload')) { - registerGenerateCsvFromSavedObject(server, plugins, handler, handleError, logger); - registerGenerateCsvFromSavedObjectImmediate(reporting, server, plugins, logger); + if (config.get('csv', 'enablePanelActionDownload')) { + registerGenerateCsvFromSavedObject(config, server, plugins, handler, handleError, logger); + registerGenerateCsvFromSavedObjectImmediate(reporting, config, server, plugins, logger); } } diff --git a/x-pack/legacy/plugins/reporting/server/routes/index.ts b/x-pack/legacy/plugins/reporting/server/routes/index.ts index 610ab4907d369..21eeb901d9b96 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/index.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/index.ts @@ -5,16 +5,17 @@ */ import { Logger, ServerFacade } from '../../types'; -import { ReportingCore, ReportingSetupDeps } from '../types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; import { registerJobGenerationRoutes } from './generation'; import { registerJobInfoRoutes } from './jobs'; export function registerRoutes( reporting: ReportingCore, + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { - registerJobGenerationRoutes(reporting, server, plugins, logger); - registerJobInfoRoutes(reporting, server, plugins, logger); + registerJobGenerationRoutes(reporting, config, server, plugins, logger); + registerJobInfoRoutes(reporting, config, server, plugins, logger); } diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js b/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js index 071b401d2321b..b12aa44487523 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js +++ b/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js @@ -5,7 +5,6 @@ */ import Hapi from 'hapi'; -import { memoize } from 'lodash'; import { createMockReportingCore } from '../../test_helpers'; import { ExportTypesRegistry } from '../lib/export_types_registry'; @@ -23,6 +22,7 @@ import { registerJobInfoRoutes } from './jobs'; let mockServer; let exportTypesRegistry; let mockReportingPlugin; +let mockReportingConfig; const mockLogger = { error: jest.fn(), debug: jest.fn(), @@ -30,7 +30,6 @@ const mockLogger = { beforeEach(async () => { mockServer = new Hapi.Server({ debug: false, port: 8080, routes: { log: { collect: true } } }); - mockServer.config = memoize(() => ({ get: jest.fn() })); exportTypesRegistry = new ExportTypesRegistry(); exportTypesRegistry.register({ id: 'unencoded', @@ -43,8 +42,11 @@ beforeEach(async () => { jobContentEncoding: 'base64', jobContentExtension: 'pdf', }); + mockReportingPlugin = await createMockReportingCore(); mockReportingPlugin.getExportTypesRegistry = () => exportTypesRegistry; + + mockReportingConfig = { get: jest.fn(), kbnConfig: { get: jest.fn() } }; }); const mockPlugins = { @@ -70,7 +72,13 @@ test(`returns 404 if job not found`, async () => { callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(getHits())), }; - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + registerJobInfoRoutes( + mockReportingPlugin, + mockReportingConfig, + mockServer, + mockPlugins, + mockLogger + ); const request = { method: 'GET', @@ -89,7 +97,13 @@ test(`returns 401 if not valid job type`, async () => { .mockReturnValue(Promise.resolve(getHits({ jobtype: 'invalidJobType' }))), }; - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + registerJobInfoRoutes( + mockReportingPlugin, + mockReportingConfig, + mockServer, + mockPlugins, + mockLogger + ); const request = { method: 'GET', @@ -110,7 +124,13 @@ describe(`when job is incomplete`, () => { ), }; - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + registerJobInfoRoutes( + mockReportingPlugin, + mockReportingConfig, + mockServer, + mockPlugins, + mockLogger + ); const request = { method: 'GET', @@ -152,7 +172,13 @@ describe(`when job is failed`, () => { callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), }; - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + registerJobInfoRoutes( + mockReportingPlugin, + mockReportingConfig, + mockServer, + mockPlugins, + mockLogger + ); const request = { method: 'GET', @@ -197,7 +223,13 @@ describe(`when job is completed`, () => { callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), }; - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + registerJobInfoRoutes( + mockReportingPlugin, + mockReportingConfig, + mockServer, + mockPlugins, + mockLogger + ); const request = { method: 'GET', diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts index b9aa75e0ddd00..4f29e561431fa 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts @@ -17,7 +17,7 @@ import { ServerFacade, } from '../../types'; import { jobsQueryFactory } from '../lib/jobs_query'; -import { ReportingSetupDeps, ReportingCore } from '../types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; import { deleteJobResponseHandlerFactory, downloadJobResponseHandlerFactory, @@ -37,13 +37,14 @@ function isResponse(response: Boom | ResponseObject): response is Response export function registerJobInfoRoutes( reporting: ReportingCore, + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { const { elasticsearch } = plugins; - const jobsQuery = jobsQueryFactory(server, elasticsearch); - const getRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); + const jobsQuery = jobsQueryFactory(config, elasticsearch); + const getRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); // list jobs in the queue, paginated server.route({ @@ -141,8 +142,8 @@ export function registerJobInfoRoutes( // trigger a download of the output from a job const exportTypesRegistry = reporting.getExportTypesRegistry(); - const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(server, plugins, logger); - const downloadResponseHandler = downloadJobResponseHandlerFactory(server, elasticsearch, exportTypesRegistry); // prettier-ignore + const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(config, plugins, logger); + const downloadResponseHandler = downloadJobResponseHandlerFactory(config, elasticsearch, exportTypesRegistry); // prettier-ignore server.route({ path: `${MAIN_ENTRY}/download/{docId}`, method: 'GET', @@ -181,8 +182,8 @@ export function registerJobInfoRoutes( }); // allow a report to be deleted - const getRouteConfigDelete = getRouteConfigFactoryDeletePre(server, plugins, logger); - const deleteResponseHandler = deleteJobResponseHandlerFactory(server, elasticsearch); + const getRouteConfigDelete = getRouteConfigFactoryDeletePre(config, plugins, logger); + const deleteResponseHandler = deleteJobResponseHandlerFactory(config, elasticsearch); server.route({ path: `${MAIN_ENTRY}/delete/{docId}`, method: 'DELETE', diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js index 3460d22592e3d..b5d6ae59ce5dd 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js @@ -7,56 +7,48 @@ import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; describe('authorized_user_pre_routing', function() { - // the getClientShield is using `once` which forces us to use a constant mock - // which makes testing anything that is dependent on `oncePerServer` confusing. - // so createMockServer reuses the same 'instance' of the server and overwrites - // the properties to contain different values - const createMockServer = (function() { - const getUserStub = jest.fn(); - let mockConfig; - - const mockServer = { - expose() {}, - config() { - return { - get(key) { - return mockConfig[key]; - }, - }; - }, - log: function() {}, - plugins: { - xpack_main: {}, - security: { getUser: getUserStub }, - }, + const createMockConfig = (mockConfig = {}) => { + return { + get: (...keys) => mockConfig[keys.join('.')], + kbnConfig: { get: (...keys) => mockConfig[keys.join('.')] }, }; + }; + const createMockPlugins = (function() { + const getUserStub = jest.fn(); return function({ securityEnabled = true, xpackInfoUndefined = false, xpackInfoAvailable = true, + getCurrentUser = undefined, user = undefined, - config = {}, }) { - mockConfig = config; - - mockServer.plugins.xpack_main = { - info: !xpackInfoUndefined && { - isAvailable: () => xpackInfoAvailable, - feature(featureName) { - if (featureName === 'security') { - return { - isEnabled: () => securityEnabled, - isAvailable: () => xpackInfoAvailable, - }; + getUserStub.mockReset(); + getUserStub.mockResolvedValue(user); + return { + security: securityEnabled + ? { + authc: { getCurrentUser }, } + : null, + __LEGACY: { + plugins: { + xpack_main: { + info: !xpackInfoUndefined && { + isAvailable: () => xpackInfoAvailable, + feature(featureName) { + if (featureName === 'security') { + return { + isEnabled: () => securityEnabled, + isAvailable: () => xpackInfoAvailable, + }; + } + }, + }, + }, }, }, }; - - getUserStub.mockReset(); - getUserStub.mockResolvedValue(user); - return mockServer; }; })(); @@ -75,10 +67,6 @@ describe('authorized_user_pre_routing', function() { raw: { req: mockRequestRaw }, }); - const getMockPlugins = pluginSet => { - return pluginSet || { security: null }; - }; - const getMockLogger = () => ({ warn: jest.fn(), error: msg => { @@ -87,11 +75,9 @@ describe('authorized_user_pre_routing', function() { }); it('should return with boom notFound when xpackInfo is undefined', async function() { - const mockServer = createMockServer({ xpackInfoUndefined: true }); - const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, - getMockPlugins(), + createMockConfig(), + createMockPlugins({ xpackInfoUndefined: true }), getMockLogger() ); const response = await authorizedUserPreRouting(getMockRequest()); @@ -100,11 +86,9 @@ describe('authorized_user_pre_routing', function() { }); it(`should return with boom notFound when xpackInfo isn't available`, async function() { - const mockServer = createMockServer({ xpackInfoAvailable: false }); - const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, - getMockPlugins(), + createMockConfig(), + createMockPlugins({ xpackInfoAvailable: false }), getMockLogger() ); const response = await authorizedUserPreRouting(getMockRequest()); @@ -113,11 +97,9 @@ describe('authorized_user_pre_routing', function() { }); it('should return with null user when security is disabled in Elasticsearch', async function() { - const mockServer = createMockServer({ securityEnabled: false }); - const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, - getMockPlugins(), + createMockConfig(), + createMockPlugins({ securityEnabled: false }), getMockLogger() ); const response = await authorizedUserPreRouting(getMockRequest()); @@ -125,16 +107,14 @@ describe('authorized_user_pre_routing', function() { }); it('should return with boom unauthenticated when security is enabled but no authenticated user', async function() { - const mockServer = createMockServer({ + const mockPlugins = createMockPlugins({ user: null, config: { 'xpack.reporting.roles.allow': ['.reporting_user'] }, }); - const mockPlugins = getMockPlugins({ - security: { authc: { getCurrentUser: () => null } }, - }); + mockPlugins.security = { authc: { getCurrentUser: () => null } }; const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, + createMockConfig(), mockPlugins, getMockLogger() ); @@ -144,16 +124,14 @@ describe('authorized_user_pre_routing', function() { }); it(`should return with boom forbidden when security is enabled but user doesn't have allowed role`, async function() { - const mockServer = createMockServer({ + const mockConfig = createMockConfig({ 'roles.allow': ['.reporting_user'] }); + const mockPlugins = createMockPlugins({ user: { roles: [] }, - config: { 'xpack.reporting.roles.allow': ['.reporting_user'] }, - }); - const mockPlugins = getMockPlugins({ - security: { authc: { getCurrentUser: () => ({ roles: ['something_else'] }) } }, + getCurrentUser: () => ({ roles: ['something_else'] }), }); const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, + mockConfig, mockPlugins, getMockLogger() ); @@ -164,18 +142,14 @@ describe('authorized_user_pre_routing', function() { it('should return with user when security is enabled and user has explicitly allowed role', async function() { const user = { roles: ['.reporting_user', 'something_else'] }; - const mockServer = createMockServer({ + const mockConfig = createMockConfig({ 'roles.allow': ['.reporting_user'] }); + const mockPlugins = createMockPlugins({ user, - config: { 'xpack.reporting.roles.allow': ['.reporting_user'] }, - }); - const mockPlugins = getMockPlugins({ - security: { - authc: { getCurrentUser: () => ({ roles: ['.reporting_user', 'something_else'] }) }, - }, + getCurrentUser: () => ({ roles: ['.reporting_user', 'something_else'] }), }); const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, + mockConfig, mockPlugins, getMockLogger() ); @@ -185,16 +159,13 @@ describe('authorized_user_pre_routing', function() { it('should return with user when security is enabled and user has superuser role', async function() { const user = { roles: ['superuser', 'something_else'] }; - const mockServer = createMockServer({ - user, - config: { 'xpack.reporting.roles.allow': [] }, - }); - const mockPlugins = getMockPlugins({ - security: { authc: { getCurrentUser: () => ({ roles: ['superuser', 'something_else'] }) } }, + const mockConfig = createMockConfig({ 'roles.allow': [] }); + const mockPlugins = createMockPlugins({ + getCurrentUser: () => ({ roles: ['superuser', 'something_else'] }), }); const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, + mockConfig, mockPlugins, getMockLogger() ); diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts index c5f8c78016f61..1ca28ca62a7f2 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts @@ -7,7 +7,8 @@ import Boom from 'boom'; import { Legacy } from 'kibana'; import { AuthenticatedUser } from '../../../../../../plugins/security/server'; -import { Logger, ServerFacade } from '../../../types'; +import { ReportingConfig } from '../../../server'; +import { Logger } from '../../../types'; import { getUserFactory } from '../../lib/get_user'; import { ReportingSetupDeps } from '../../types'; @@ -18,16 +19,14 @@ export type PreRoutingFunction = ( ) => Promise | AuthenticatedUser | null>; export const authorizedUserPreRoutingFactory = function authorizedUserPreRoutingFn( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ) { - const getUser = getUserFactory(server, plugins.security); - const config = server.config(); + const getUser = getUserFactory(plugins.security, logger); + const { info: xpackInfo } = plugins.__LEGACY.plugins.xpack_main; return async function authorizedUserPreRouting(request: Legacy.Request) { - const xpackInfo = server.plugins.xpack_main.info; - if (!xpackInfo || !xpackInfo.isAvailable()) { logger.warn('Unable to authorize user before xpack info is available.', [ 'authorizedUserPreRouting', @@ -46,10 +45,7 @@ export const authorizedUserPreRoutingFactory = function authorizedUserPreRouting return Boom.unauthorized(`Sorry, you aren't authenticated`); } - const authorizedRoles = [ - superuserRole, - ...(config.get('xpack.reporting.roles.allow') as string[]), - ]; + const authorizedRoles = [superuserRole, ...(config.get('roles', 'allow') as string[])]; if (!user.roles.find(role => authorizedRoles.includes(role))) { return Boom.forbidden(`Sorry, you don't have access to Reporting`); } diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts index fb3944ea33552..aef37754681ec 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts @@ -8,13 +8,7 @@ import contentDisposition from 'content-disposition'; import * as _ from 'lodash'; import { CSV_JOB_TYPE } from '../../../common/constants'; -import { - ExportTypeDefinition, - ExportTypesRegistry, - JobDocOutput, - JobSource, - ServerFacade, -} from '../../../types'; +import { ExportTypeDefinition, ExportTypesRegistry, JobDocOutput, JobSource } from '../../../types'; interface ICustomHeaders { [x: string]: any; @@ -22,9 +16,15 @@ interface ICustomHeaders { type ExportTypeType = ExportTypeDefinition; +interface ErrorFromPayload { + message: string; + reason: string | null; +} + +// A camelCase version of JobDocOutput interface Payload { statusCode: number; - content: any; + content: string | Buffer | ErrorFromPayload; contentType: string; headers: Record; } @@ -48,20 +48,17 @@ const getReportingHeaders = (output: JobDocOutput, exportType: ExportTypeType) = return metaDataHeaders; }; -export function getDocumentPayloadFactory( - server: ServerFacade, - exportTypesRegistry: ExportTypesRegistry -) { - function encodeContent(content: string | null, exportType: ExportTypeType) { +export function getDocumentPayloadFactory(exportTypesRegistry: ExportTypesRegistry) { + function encodeContent(content: string | null, exportType: ExportTypeType): Buffer | string { switch (exportType.jobContentEncoding) { case 'base64': - return content ? Buffer.from(content, 'base64') : content; // Buffer.from rejects null + return content ? Buffer.from(content, 'base64') : ''; // convert null to empty string default: - return content; + return content ? content : ''; // convert null to empty string } } - function getCompleted(output: JobDocOutput, jobType: string, title: string) { + function getCompleted(output: JobDocOutput, jobType: string, title: string): Payload { const exportType = exportTypesRegistry.get((item: ExportTypeType) => item.jobType === jobType); const filename = getTitle(exportType, title); const headers = getReportingHeaders(output, exportType); @@ -77,7 +74,7 @@ export function getDocumentPayloadFactory( }; } - function getFailure(output: JobDocOutput) { + function getFailure(output: JobDocOutput): Payload { return { statusCode: 500, content: { diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts index 30627d5b23230..e7e7c866db96a 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts @@ -5,11 +5,12 @@ */ import Boom from 'boom'; -import { ElasticsearchServiceSetup } from 'kibana/server'; import { ResponseToolkit } from 'hapi'; +import { ElasticsearchServiceSetup } from 'kibana/server'; import { WHITELISTED_JOB_CONTENT_TYPES } from '../../../common/constants'; -import { ExportTypesRegistry, ServerFacade } from '../../../types'; +import { ExportTypesRegistry } from '../../../types'; import { jobsQueryFactory } from '../../lib/jobs_query'; +import { ReportingConfig } from '../../types'; import { getDocumentPayloadFactory } from './get_document_payload'; interface JobResponseHandlerParams { @@ -21,12 +22,12 @@ interface JobResponseHandlerOpts { } export function downloadJobResponseHandlerFactory( - server: ServerFacade, + config: ReportingConfig, elasticsearch: ElasticsearchServiceSetup, exportTypesRegistry: ExportTypesRegistry ) { - const jobsQuery = jobsQueryFactory(server, elasticsearch); - const getDocumentPayload = getDocumentPayloadFactory(server, exportTypesRegistry); + const jobsQuery = jobsQueryFactory(config, elasticsearch); + const getDocumentPayload = getDocumentPayloadFactory(exportTypesRegistry); return function jobResponseHandler( validJobTypes: string[], @@ -70,10 +71,10 @@ export function downloadJobResponseHandlerFactory( } export function deleteJobResponseHandlerFactory( - server: ServerFacade, + config: ReportingConfig, elasticsearch: ElasticsearchServiceSetup ) { - const jobsQuery = jobsQueryFactory(server, elasticsearch); + const jobsQuery = jobsQueryFactory(config, elasticsearch); return async function deleteJobResponseHander( validJobTypes: string[], diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts index 9e618ff1fe40a..8a79566aafae2 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts @@ -6,17 +6,17 @@ import Boom from 'boom'; import { Legacy } from 'kibana'; -import { Logger, ServerFacade } from '../../../types'; -import { ReportingSetupDeps } from '../../types'; +import { Logger } from '../../../types'; +import { ReportingConfig, ReportingSetupDeps } from '../../types'; export type GetReportingFeatureIdFn = (request: Legacy.Request) => string; export const reportingFeaturePreRoutingFactory = function reportingFeaturePreRoutingFn( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ) { - const xpackMainPlugin = server.plugins.xpack_main; + const xpackMainPlugin = plugins.__LEGACY.plugins.xpack_main; const pluginId = 'reporting'; // License checking and enable/disable logic diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts index 3d275d34e2f7d..06f7efaa9dcbb 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts @@ -6,8 +6,8 @@ import Joi from 'joi'; import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; -import { Logger, ServerFacade } from '../../../types'; -import { ReportingSetupDeps } from '../../types'; +import { Logger } from '../../../types'; +import { ReportingConfig, ReportingSetupDeps } from '../../types'; import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; import { GetReportingFeatureIdFn, @@ -29,12 +29,12 @@ export type GetRouteConfigFactoryFn = ( ) => RouteConfigFactory; export function getRouteConfigFactoryReportingPre( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const authorizedUserPreRouting = authorizedUserPreRoutingFactory(server, plugins, logger); - const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(server, plugins, logger); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory(config, plugins, logger); + const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(config, plugins, logger); return (getFeatureId?: GetReportingFeatureIdFn): RouteConfigFactory => { const preRouting: any[] = [{ method: authorizedUserPreRouting, assign: 'user' }]; @@ -50,11 +50,11 @@ export function getRouteConfigFactoryReportingPre( } export function getRouteOptionsCsv( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ) { - const getRouteConfig = getRouteConfigFactoryReportingPre(server, plugins, logger); + const getRouteConfig = getRouteConfigFactoryReportingPre(config, plugins, logger); return { ...getRouteConfig(() => CSV_FROM_SAVEDOBJECT_JOB_TYPE), validate: { @@ -75,12 +75,12 @@ export function getRouteOptionsCsv( } export function getRouteConfigFactoryManagementPre( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const authorizedUserPreRouting = authorizedUserPreRoutingFactory(server, plugins, logger); - const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(server, plugins, logger); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory(config, plugins, logger); + const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(config, plugins, logger); const managementPreRouting = reportingFeaturePreRouting(() => 'management'); return (): RouteConfigFactory => { @@ -99,11 +99,11 @@ export function getRouteConfigFactoryManagementPre( // Additionally, the range-request doesn't alleviate any performance issues on the server as the entire // download is loaded into memory. export function getRouteConfigFactoryDownloadPre( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); + const getManagementRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); return (): RouteConfigFactory => ({ ...getManagementRouteConfig(), tags: [API_TAG, 'download'], @@ -114,11 +114,11 @@ export function getRouteConfigFactoryDownloadPre( } export function getRouteConfigFactoryDeletePre( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); + const getManagementRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); return (): RouteConfigFactory => ({ ...getManagementRouteConfig(), tags: [API_TAG, 'delete'], diff --git a/x-pack/legacy/plugins/reporting/server/types.d.ts b/x-pack/legacy/plugins/reporting/server/types.d.ts index 59b7bc2020ad9..c773e2d556648 100644 --- a/x-pack/legacy/plugins/reporting/server/types.d.ts +++ b/x-pack/legacy/plugins/reporting/server/types.d.ts @@ -11,16 +11,17 @@ import { PluginStart as DataPluginStart } from '../../../../../src/plugins/data/ import { SecurityPluginSetup } from '../../../../plugins/security/server'; import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; import { ReportingPluginSpecOptions } from '../types'; +import { ReportingConfig, ReportingConfigType } from './core'; export interface ReportingSetupDeps { elasticsearch: ElasticsearchServiceSetup; security: SecurityPluginSetup; usageCollection: UsageCollectionSetup; + reporting: { config: ReportingConfig }; __LEGACY: LegacySetup; } export interface ReportingStartDeps { - elasticsearch: ElasticsearchServiceSetup; data: DataPluginStart; __LEGACY: LegacySetup; } @@ -30,10 +31,7 @@ export type ReportingSetup = object; export type ReportingStart = object; export interface LegacySetup { - config: Legacy.Server['config']; - info: Legacy.Server['info']; plugins: { - elasticsearch: Legacy.Server['plugins']['elasticsearch']; xpack_main: XPackMainPlugin & { status?: any; }; @@ -42,4 +40,7 @@ export interface LegacySetup { route: Legacy.Server['route']; } -export { ReportingCore } from './core'; +export { ReportingConfig, ReportingCore } from './core'; + +export type CaptureConfig = ReportingConfigType['capture']; +export type ScrollConfig = ReportingConfigType['csv']['scroll']; diff --git a/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts b/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts index bd2d0cb835a79..5f12f2b7f044d 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts +++ b/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts @@ -5,7 +5,10 @@ */ import { get } from 'lodash'; -import { ServerFacade, ExportTypesRegistry, ESCallCluster } from '../../types'; +import { ESCallCluster, ExportTypesRegistry } from '../../types'; +import { ReportingConfig, ReportingSetupDeps } from '../types'; +import { decorateRangeStats } from './decorate_range_stats'; +import { getExportTypesHandler } from './get_export_type_handler'; import { AggregationBuckets, AggregationResults, @@ -15,8 +18,6 @@ import { RangeAggregationResults, RangeStats, } from './types'; -import { decorateRangeStats } from './decorate_range_stats'; -import { getExportTypesHandler } from './get_export_type_handler'; const JOB_TYPES_KEY = 'jobTypes'; const JOB_TYPES_FIELD = 'jobtype'; @@ -79,10 +80,7 @@ type RangeStatSets = Partial< last7Days: RangeStats; } >; -async function handleResponse( - server: ServerFacade, - response: AggregationResults -): Promise { +async function handleResponse(response: AggregationResults): Promise { const buckets = get(response, 'aggregations.ranges.buckets'); if (!buckets) { return {}; @@ -101,12 +99,12 @@ async function handleResponse( } export async function getReportingUsage( - server: ServerFacade, + config: ReportingConfig, + plugins: ReportingSetupDeps, callCluster: ESCallCluster, exportTypesRegistry: ExportTypesRegistry ) { - const config = server.config(); - const reportingIndex = config.get('xpack.reporting.index'); + const reportingIndex = config.get('index'); const params = { index: `${reportingIndex}-*`, @@ -139,16 +137,18 @@ export async function getReportingUsage( }, }; + const { info: xpackMainInfo } = plugins.__LEGACY.plugins.xpack_main; return callCluster('search', params) - .then((response: AggregationResults) => handleResponse(server, response)) + .then((response: AggregationResults) => handleResponse(response)) .then((usage: RangeStatSets) => { // Allow this to explicitly throw an exception if/when this config is deprecated, // because we shouldn't collect browserType in that case! - const browserType = config.get('xpack.reporting.capture.browser.type'); + const browserType = config.get('capture', 'browser', 'type'); - const xpackInfo = server.plugins.xpack_main.info; const exportTypesHandler = getExportTypesHandler(exportTypesRegistry); - const availability = exportTypesHandler.getAvailability(xpackInfo) as FeatureAvailabilityMap; + const availability = exportTypesHandler.getAvailability( + xpackMainInfo + ) as FeatureAvailabilityMap; const { lastDay, last7Days, ...all } = usage; diff --git a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js index a6d753f9b107a..905d2fe9b995c 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js +++ b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js @@ -24,62 +24,52 @@ function getMockUsageCollection() { makeUsageCollector: options => { return new MockUsageCollector(this, options); }, + registerCollector: sinon.stub(), }; } -function getServerMock(customization) { - const getLicenseCheckResults = sinon.stub().returns({}); - const defaultServerMock = { - plugins: { - security: { - isAuthenticated: sinon.stub().returns(true), - }, - xpack_main: { - info: { - isAvailable: sinon.stub().returns(true), - feature: () => ({ - getLicenseCheckResults, - }), - license: { - isOneOf: sinon.stub().returns(false), - getType: sinon.stub().returns('platinum'), - }, - toJSON: () => ({ b: 1 }), - }, +function getPluginsMock( + { license, usageCollection = getMockUsageCollection() } = { license: 'platinum' } +) { + const mockXpackMain = { + info: { + isAvailable: sinon.stub().returns(true), + feature: () => ({ + getLicenseCheckResults: sinon.stub(), + }), + license: { + isOneOf: sinon.stub().returns(false), + getType: sinon.stub().returns(license), }, + toJSON: () => ({ b: 1 }), }, - log: () => {}, - config: () => ({ - get: key => { - if (key === 'xpack.reporting.enabled') { - return true; - } else if (key === 'xpack.reporting.index') { - return '.reporting-index'; - } + }; + return { + usageCollection, + __LEGACY: { + plugins: { + xpack_main: mockXpackMain, }, - }), + }, }; - return Object.assign(defaultServerMock, customization); } const getResponseMock = (customization = {}) => customization; describe('license checks', () => { + let mockConfig; + beforeAll(async () => { + const mockReporting = await createMockReportingCore(); + mockConfig = await mockReporting.getConfig(); + }); + describe('with a basic license', () => { let usageStats; beforeAll(async () => { - const serverWithBasicLicenseMock = getServerMock(); - serverWithBasicLicenseMock.plugins.xpack_main.info.license.getType = sinon - .stub() - .returns('basic'); + const plugins = getPluginsMock({ license: 'basic' }); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const usageCollection = getMockUsageCollection(); - const { fetch: getReportingUsage } = getReportingUsageCollector( - serverWithBasicLicenseMock, - usageCollection, - exportTypesRegistry - ); - usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); + const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); + usageStats = await fetch(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -98,18 +88,10 @@ describe('license checks', () => { describe('with no license', () => { let usageStats; beforeAll(async () => { - const serverWithNoLicenseMock = getServerMock(); - serverWithNoLicenseMock.plugins.xpack_main.info.license.getType = sinon - .stub() - .returns('none'); + const plugins = getPluginsMock({ license: 'none' }); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const usageCollection = getMockUsageCollection(); - const { fetch: getReportingUsage } = getReportingUsageCollector( - serverWithNoLicenseMock, - usageCollection, - exportTypesRegistry - ); - usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); + const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); + usageStats = await fetch(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -128,18 +110,10 @@ describe('license checks', () => { describe('with platinum license', () => { let usageStats; beforeAll(async () => { - const serverWithPlatinumLicenseMock = getServerMock(); - serverWithPlatinumLicenseMock.plugins.xpack_main.info.license.getType = sinon - .stub() - .returns('platinum'); + const plugins = getPluginsMock({ license: 'platinum' }); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const usageCollection = getMockUsageCollection(); - const { fetch: getReportingUsage } = getReportingUsageCollector( - serverWithPlatinumLicenseMock, - usageCollection, - exportTypesRegistry - ); - usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); + const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); + usageStats = await fetch(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -158,18 +132,10 @@ describe('license checks', () => { describe('with no usage data', () => { let usageStats; beforeAll(async () => { - const serverWithBasicLicenseMock = getServerMock(); - serverWithBasicLicenseMock.plugins.xpack_main.info.license.getType = sinon - .stub() - .returns('basic'); + const plugins = getPluginsMock({ license: 'basic' }); const callClusterMock = jest.fn(() => Promise.resolve({})); - const usageCollection = getMockUsageCollection(); - const { fetch: getReportingUsage } = getReportingUsageCollector( - serverWithBasicLicenseMock, - usageCollection, - exportTypesRegistry - ); - usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); + const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); + usageStats = await fetch(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -183,21 +149,11 @@ describe('license checks', () => { }); describe('data modeling', () => { - let getReportingUsage; - beforeAll(async () => { - const usageCollection = getMockUsageCollection(); - const serverWithPlatinumLicenseMock = getServerMock(); - serverWithPlatinumLicenseMock.plugins.xpack_main.info.license.getType = sinon - .stub() - .returns('platinum'); - ({ fetch: getReportingUsage } = getReportingUsageCollector( - serverWithPlatinumLicenseMock, - usageCollection, - exportTypesRegistry - )); - }); - test('with normal looking usage data', async () => { + const mockReporting = await createMockReportingCore(); + const mockConfig = await mockReporting.getConfig(); + const plugins = getPluginsMock(); + const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); const callClusterMock = jest.fn(() => Promise.resolve( getResponseMock({ @@ -320,7 +276,7 @@ describe('data modeling', () => { ) ); - const usageStats = await getReportingUsage(callClusterMock); + const usageStats = await fetch(callClusterMock); expect(usageStats).toMatchInlineSnapshot(` Object { "PNG": Object { @@ -415,20 +371,16 @@ describe('data modeling', () => { }); describe('Ready for collection observable', () => { - let mockReporting; - - beforeEach(async () => { - mockReporting = await createMockReportingCore(); - }); - test('converts observable to promise', async () => { - const serverWithBasicLicenseMock = getServerMock(); + const mockReporting = await createMockReportingCore(); + const mockConfig = await mockReporting.getConfig(); + + const usageCollection = getMockUsageCollection(); const makeCollectorSpy = sinon.spy(); - const usageCollection = { - makeUsageCollector: makeCollectorSpy, - registerCollector: sinon.stub(), - }; - registerReportingUsageCollector(mockReporting, serverWithBasicLicenseMock, usageCollection); + usageCollection.makeUsageCollector = makeCollectorSpy; + + const plugins = getPluginsMock({ usageCollection }); + registerReportingUsageCollector(mockReporting, mockConfig, plugins); const [args] = makeCollectorSpy.firstCall.args; expect(args).toMatchInlineSnapshot(` diff --git a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts index 14202530fb6c7..ab4ec3a0edf57 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts +++ b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { KIBANA_REPORTING_TYPE } from '../../common/constants'; -import { ReportingCore } from '../../server'; -import { ESCallCluster, ExportTypesRegistry, ServerFacade } from '../../types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../../server/types'; +import { ESCallCluster, ExportTypesRegistry } from '../../types'; import { getReportingUsage } from './get_reporting_usage'; import { RangeStats } from './types'; @@ -15,19 +14,19 @@ import { RangeStats } from './types'; const METATYPE = 'kibana_stats'; /* - * @param {Object} server * @return {Object} kibana usage stats type collection object */ export function getReportingUsageCollector( - server: ServerFacade, - usageCollection: UsageCollectionSetup, + config: ReportingConfig, + plugins: ReportingSetupDeps, exportTypesRegistry: ExportTypesRegistry, isReady: () => Promise ) { + const { usageCollection } = plugins; return usageCollection.makeUsageCollector({ type: KIBANA_REPORTING_TYPE, fetch: (callCluster: ESCallCluster) => - getReportingUsage(server, callCluster, exportTypesRegistry), + getReportingUsage(config, plugins, callCluster, exportTypesRegistry), isReady, /* @@ -52,17 +51,17 @@ export function getReportingUsageCollector( export function registerReportingUsageCollector( reporting: ReportingCore, - server: ServerFacade, - usageCollection: UsageCollectionSetup + config: ReportingConfig, + plugins: ReportingSetupDeps ) { const exportTypesRegistry = reporting.getExportTypesRegistry(); const collectionIsReady = reporting.pluginHasStarted.bind(reporting); const collector = getReportingUsageCollector( - server, - usageCollection, + config, + plugins, exportTypesRegistry, collectionIsReady ); - usageCollection.registerCollector(collector); + plugins.usageCollection.registerCollector(collector); } diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts index 883276d43e27e..930aa7601b8cb 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts @@ -10,7 +10,8 @@ import * as contexts from '../export_types/common/lib/screenshots/constants'; import { ElementsPositionAndAttribute } from '../export_types/common/lib/screenshots/types'; import { HeadlessChromiumDriver, HeadlessChromiumDriverFactory } from '../server/browsers'; import { createDriverFactory } from '../server/browsers/chromium'; -import { BrowserConfig, CaptureConfig, Logger } from '../types'; +import { CaptureConfig } from '../server/types'; +import { Logger } from '../types'; interface CreateMockBrowserDriverFactoryOpts { evaluate: jest.Mock, any[]>; @@ -93,24 +94,34 @@ export const createMockBrowserDriverFactory = async ( logger: Logger, opts: Partial ): Promise => { - const browserConfig = { - inspect: true, - userDataDir: '/usr/data/dir', - viewport: { width: 12, height: 12 }, - disableSandbox: false, - proxy: { enabled: false }, - } as BrowserConfig; + const captureConfig = { + timeouts: { openUrl: 30000, waitForElements: 30000, renderComplete: 30000 }, + browser: { + type: 'chromium', + chromium: { + inspect: false, + disableSandbox: false, + userDataDir: '/usr/data/dir', + viewport: { width: 12, height: 12 }, + proxy: { enabled: false, server: undefined, bypass: undefined }, + }, + autoDownload: false, + inspect: true, + userDataDir: '/usr/data/dir', + viewport: { width: 12, height: 12 }, + disableSandbox: false, + proxy: { enabled: false, server: undefined, bypass: undefined }, + maxScreenshotDimension: undefined, + }, + networkPolicy: { enabled: true, rules: [] }, + viewport: { width: 800, height: 600 }, + loadDelay: 2000, + zoom: 1, + maxAttempts: 1, + } as CaptureConfig; const binaryPath = '/usr/local/share/common/secure/'; - const captureConfig = { networkPolicy: {}, timeouts: {} } as CaptureConfig; - - const mockBrowserDriverFactory = await createDriverFactory( - binaryPath, - logger, - browserConfig, - captureConfig - ); - + const mockBrowserDriverFactory = await createDriverFactory(binaryPath, logger, captureConfig); const mockPage = {} as Page; const mockBrowserDriver = new HeadlessChromiumDriver(mockPage, { inspect: true, diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts index 0250e6c0a9afd..be60b56dcc0c1 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createLayout } from '../export_types/common/layouts'; import { LayoutTypes } from '../export_types/common/constants'; +import { createLayout } from '../export_types/common/layouts'; import { LayoutInstance } from '../export_types/common/layouts/layout'; -import { ServerFacade } from '../types'; +import { CaptureConfig } from '../server/types'; -export const createMockLayoutInstance = (__LEGACY: ServerFacade) => { - const mockLayout = createLayout(__LEGACY, { +export const createMockLayoutInstance = (captureConfig: CaptureConfig) => { + const mockLayout = createLayout(captureConfig, { id: LayoutTypes.PRESERVE_LAYOUT, dimensions: { height: 12, width: 12 }, }) as LayoutInstance; diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts index 2cd129d47b3f9..332b37b58cb7d 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts @@ -19,16 +19,24 @@ import { coreMock } from 'src/core/server/mocks'; import { ReportingPlugin, ReportingCore } from '../server'; import { ReportingSetupDeps, ReportingStartDeps } from '../server/types'; -export const createMockSetupDeps = (setupMock?: any): ReportingSetupDeps => ({ - elasticsearch: setupMock.elasticsearch, - security: setupMock.security, - usageCollection: {} as any, - __LEGACY: { plugins: { xpack_main: { status: new EventEmitter() } } } as any, -}); +const createMockSetupDeps = (setupMock?: any): ReportingSetupDeps => { + const configGetStub = jest.fn(); + return { + elasticsearch: setupMock.elasticsearch, + security: setupMock.security, + usageCollection: {} as any, + reporting: { + config: { + get: configGetStub, + kbnConfig: { get: configGetStub }, + }, + }, + __LEGACY: { plugins: { xpack_main: { status: new EventEmitter() } } } as any, + }; +}; export const createMockStartDeps = (startMock?: any): ReportingStartDeps => ({ data: startMock.data, - elasticsearch: startMock.elasticsearch, __LEGACY: {} as any, }); diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts index bb7851ba036a9..531e1dcaf84e0 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts @@ -3,36 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { memoize } from 'lodash'; -import { ServerFacade } from '../types'; - -export const createMockServer = ({ settings = {} }: any): ServerFacade => { - const mockServer = { - config: memoize(() => ({ get: jest.fn() })), - info: { - protocol: 'http', - }, - plugins: { - elasticsearch: { - getCluster: memoize(() => { - return { - callWithRequest: jest.fn(), - }; - }), - }, - }, - }; - const defaultSettings: any = { - 'xpack.reporting.encryptionKey': 'testencryptionkey', - 'server.basePath': '/sbp', - 'server.host': 'localhost', - 'server.port': 5601, - 'xpack.reporting.kibanaServer': {}, - }; - mockServer.config().get.mockImplementation((key: any) => { - return key in settings ? settings[key] : defaultSettings[key]; - }); +import { ServerFacade } from '../types'; - return (mockServer as unknown) as ServerFacade; +export const createMockServer = (): ServerFacade => { + const mockServer = {}; + return mockServer as any; }; diff --git a/x-pack/legacy/plugins/reporting/types.d.ts b/x-pack/legacy/plugins/reporting/types.d.ts index 238079ba92a29..76253752be1b7 100644 --- a/x-pack/legacy/plugins/reporting/types.d.ts +++ b/x-pack/legacy/plugins/reporting/types.d.ts @@ -7,14 +7,11 @@ import { EventEmitter } from 'events'; import { ResponseObject } from 'hapi'; import { Legacy } from 'kibana'; -import { ElasticsearchServiceSetup } from 'kibana/server'; import { CallCluster } from '../../../../src/legacy/core_plugins/elasticsearch'; import { CancellationToken } from './common/cancellation_token'; -import { HeadlessChromiumDriverFactory } from './server/browsers/chromium/driver_factory'; -import { BrowserType } from './server/browsers/types'; -import { LevelLogger } from './server/lib/level_logger'; import { ReportingCore } from './server/core'; -import { LegacySetup, ReportingStartDeps, ReportingSetup, ReportingStart } from './server/types'; +import { LevelLogger } from './server/lib/level_logger'; +import { LegacySetup } from './server/types'; export type Job = EventEmitter & { id: string; @@ -25,8 +22,8 @@ export type Job = EventEmitter & { export interface NetworkPolicyRule { allow: boolean; - protocol: string; - host: string; + protocol?: string; + host?: string; } export interface NetworkPolicy { @@ -93,51 +90,6 @@ export type ReportingResponseToolkit = Legacy.ResponseToolkit; export type ESCallCluster = CallCluster; -/* - * Reporting Config - */ - -export interface CaptureConfig { - browser: { - type: BrowserType; - autoDownload: boolean; - chromium: BrowserConfig; - }; - maxAttempts: number; - networkPolicy: NetworkPolicy; - loadDelay: number; - timeouts: { - openUrl: number; - waitForElements: number; - renderComplet: number; - }; -} - -export interface BrowserConfig { - inspect: boolean; - userDataDir: string; - viewport: { width: number; height: number }; - disableSandbox: boolean; - proxy: { - enabled: boolean; - server: string; - bypass?: string[]; - }; -} - -export interface QueueConfig { - indexInterval: string; - pollEnabled: boolean; - pollInterval: number; - pollIntervalErrorMultiplier: number; - timeout: number; -} - -export interface ScrollConfig { - duration: string; - size: number; -} - export interface ElementPosition { boundingClientRect: { // modern browsers support x/y, but older ones don't @@ -274,14 +226,10 @@ export interface ESQueueInstance { export type CreateJobFactory = ( reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, logger: LevelLogger -) => CreateJobFnType; +) => Promise; export type ExecuteJobFactory = ( reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, logger: LevelLogger ) => Promise; diff --git a/x-pack/plugins/reporting/config.ts b/x-pack/plugins/reporting/config.ts deleted file mode 100644 index f1d6b1a8f248f..0000000000000 --- a/x-pack/plugins/reporting/config.ts +++ /dev/null @@ -1,10 +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. - */ - -export const reportingPollConfig = { - jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, - jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, -}; diff --git a/x-pack/plugins/reporting/kibana.json b/x-pack/plugins/reporting/kibana.json index a7e2bd288f0b1..d330eb9b7872a 100644 --- a/x-pack/plugins/reporting/kibana.json +++ b/x-pack/plugins/reporting/kibana.json @@ -1,7 +1,11 @@ { + "configPath": [ "xpack", "reporting" ], "id": "reporting", "version": "8.0.0", "kibanaVersion": "kibana", + "optionalPlugins": [ + "usageCollection" + ], "requiredPlugins": [ "home", "management", @@ -11,6 +15,6 @@ "share", "kibanaLegacy" ], - "server": false, + "server": true, "ui": true } diff --git a/x-pack/plugins/reporting/server/config/index.test.ts b/x-pack/plugins/reporting/server/config/index.test.ts new file mode 100644 index 0000000000000..08fe2c5861311 --- /dev/null +++ b/x-pack/plugins/reporting/server/config/index.test.ts @@ -0,0 +1,122 @@ +/* + * 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 * as Rx from 'rxjs'; +import { CoreSetup, Logger, PluginInitializerContext } from '../../../../../src/core/server'; +import { createConfig$ } from './'; + +interface KibanaServer { + host?: string; + port?: number; + protocol?: string; +} +interface ReportingKibanaServer { + hostname?: string; + port?: number; + protocol?: string; +} + +const makeMockInitContext = (config: { + encryptionKey?: string; + kibanaServer: ReportingKibanaServer; +}): PluginInitializerContext => + ({ + config: { create: () => Rx.of(config) }, + } as PluginInitializerContext); + +const makeMockCoreSetup = (serverInfo: KibanaServer): CoreSetup => + ({ http: { getServerInfo: () => serverInfo } } as any); + +describe('Reporting server createConfig$', () => { + let mockCoreSetup: CoreSetup; + let mockInitContext: PluginInitializerContext; + let mockLogger: Logger; + + beforeEach(() => { + mockCoreSetup = makeMockCoreSetup({ host: 'kibanaHost', port: 5601, protocol: 'http' }); + mockInitContext = makeMockInitContext({ + kibanaServer: {}, + }); + mockLogger = ({ warn: jest.fn() } as unknown) as Logger; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('creates random encryption key and default config using host, protocol, and port from server info', async () => { + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result.encryptionKey).toMatch(/\S{32,}/); + expect(result.kibanaServer).toMatchInlineSnapshot(` + Object { + "hostname": "kibanaHost", + "port": 5601, + "protocol": "http", + } + `); + expect((mockLogger.warn as any).mock.calls.length).toBe(1); + expect((mockLogger.warn as any).mock.calls[0]).toMatchObject([ + 'Generating a random key for xpack.reporting.encryptionKey. To prevent sessions from being invalidated on restart, please set xpack.reporting.encryptionKey in kibana.yml', + ]); + }); + + it('uses the encryption key', async () => { + mockInitContext = makeMockInitContext({ + encryptionKey: 'iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii', + kibanaServer: {}, + }); + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result.encryptionKey).toMatch('iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii'); + expect((mockLogger.warn as any).mock.calls.length).toBe(0); + }); + + it('uses the encryption key, reporting kibanaServer settings to override server info', async () => { + mockInitContext = makeMockInitContext({ + encryptionKey: 'iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii', + kibanaServer: { + hostname: 'reportingHost', + port: 5677, + protocol: 'httpsa', + }, + }); + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result).toMatchInlineSnapshot(` + Object { + "encryptionKey": "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii", + "kibanaServer": Object { + "hostname": "reportingHost", + "port": 5677, + "protocol": "httpsa", + }, + } + `); + expect((mockLogger.warn as any).mock.calls.length).toBe(0); + }); + + it('show warning when kibanaServer.hostName === "0"', async () => { + mockInitContext = makeMockInitContext({ + encryptionKey: 'aaaaaaaaaaaaabbbbbbbbbbbbaaaaaaaaa', + kibanaServer: { hostname: '0' }, + }); + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result.kibanaServer).toMatchInlineSnapshot(` + Object { + "hostname": "0.0.0.0", + "port": 5601, + "protocol": "http", + } + `); + expect((mockLogger.warn as any).mock.calls.length).toBe(1); + expect((mockLogger.warn as any).mock.calls[0]).toMatchObject([ + `Found 'server.host: \"0\" in Kibana configuration. This is incompatible with Reporting. To enable Reporting to work, 'xpack.reporting.kibanaServer.hostname: 0.0.0.0' is being automatically ` + + `to the configuration. You can change the setting to 'server.host: 0.0.0.0' or add 'xpack.reporting.kibanaServer.hostname: 0.0.0.0' in kibana.yml to prevent this message.`, + ]); + }); +}); diff --git a/x-pack/plugins/reporting/server/config/index.ts b/x-pack/plugins/reporting/server/config/index.ts new file mode 100644 index 0000000000000..ac51b39ae23b4 --- /dev/null +++ b/x-pack/plugins/reporting/server/config/index.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n/'; +import { TypeOf } from '@kbn/config-schema'; +import crypto from 'crypto'; +import { map } from 'rxjs/operators'; +import { PluginConfigDescriptor } from 'kibana/server'; +import { CoreSetup, Logger, PluginInitializerContext } from '../../../../../src/core/server'; +import { ConfigSchema, ConfigType } from './schema'; + +export function createConfig$(core: CoreSetup, context: PluginInitializerContext, logger: Logger) { + return context.config.create>().pipe( + map(config => { + // encryption key + let encryptionKey = config.encryptionKey; + if (encryptionKey === undefined) { + logger.warn( + i18n.translate('xpack.reporting.serverConfig.randomEncryptionKey', { + defaultMessage: + 'Generating a random key for xpack.reporting.encryptionKey. To prevent sessions from being invalidated on ' + + 'restart, please set xpack.reporting.encryptionKey in kibana.yml', + }) + ); + encryptionKey = crypto.randomBytes(16).toString('hex'); + } + + const { kibanaServer: reportingServer } = config; + const serverInfo = core.http.getServerInfo(); + + // kibanaServer.hostname, default to server.host, don't allow "0" + let kibanaServerHostname = reportingServer.hostname + ? reportingServer.hostname + : serverInfo.host; + if (kibanaServerHostname === '0') { + logger.warn( + i18n.translate('xpack.reporting.serverConfig.invalidServerHostname', { + defaultMessage: + `Found 'server.host: "0" in Kibana configuration. This is incompatible with Reporting. ` + + `To enable Reporting to work, '{configKey}: 0.0.0.0' is being automatically to the configuration. ` + + `You can change the setting to 'server.host: 0.0.0.0' or add '{configKey}: 0.0.0.0' in kibana.yml to prevent this message.`, + values: { configKey: 'xpack.reporting.kibanaServer.hostname' }, + }) + ); + kibanaServerHostname = '0.0.0.0'; + } + + // kibanaServer.port, default to server.port + const kibanaServerPort = reportingServer.port + ? reportingServer.port + : serverInfo.port; // prettier-ignore + + // kibanaServer.protocol, default to server.protocol + const kibanaServerProtocol = reportingServer.protocol + ? reportingServer.protocol + : serverInfo.protocol; + + return { + ...config, + encryptionKey, + kibanaServer: { + hostname: kibanaServerHostname, + port: kibanaServerPort, + protocol: kibanaServerProtocol, + }, + }; + }) + ); +} + +export const config: PluginConfigDescriptor = { + schema: ConfigSchema, + deprecations: ({ unused }) => [ + unused('capture.browser.chromium.maxScreenshotDimension'), + unused('capture.concurrency'), + unused('capture.settleTime'), + unused('capture.timeout'), + unused('kibanaApp'), + ], +}; + +export { ConfigSchema, ConfigType }; diff --git a/x-pack/plugins/reporting/server/config/schema.test.ts b/x-pack/plugins/reporting/server/config/schema.test.ts new file mode 100644 index 0000000000000..d8fe6d1ff084a --- /dev/null +++ b/x-pack/plugins/reporting/server/config/schema.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ConfigSchema } from './schema'; + +describe('Reporting Config Schema', () => { + it(`context {"dev":false,"dist":false} produces correct config`, () => { + expect(ConfigSchema.validate({}, { dev: false, dist: false })).toMatchObject({ + capture: { + browser: { + autoDownload: true, + chromium: { disableSandbox: false, proxy: { enabled: false } }, + type: 'chromium', + }, + loadDelay: 3000, + maxAttempts: 1, + networkPolicy: { + enabled: true, + rules: [ + { allow: true, host: undefined, protocol: 'http:' }, + { allow: true, host: undefined, protocol: 'https:' }, + { allow: true, host: undefined, protocol: 'ws:' }, + { allow: true, host: undefined, protocol: 'wss:' }, + { allow: true, host: undefined, protocol: 'data:' }, + { allow: false, host: undefined, protocol: undefined }, + ], + }, + viewport: { height: 1200, width: 1950 }, + zoom: 2, + }, + csv: { + checkForFormulas: true, + enablePanelActionDownload: true, + maxSizeBytes: 10485760, + scroll: { duration: '30s', size: 500 }, + }, + encryptionKey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + index: '.reporting', + kibanaServer: {}, + poll: { + jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, + jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, + }, + queue: { + indexInterval: 'week', + pollEnabled: true, + pollInterval: 3000, + pollIntervalErrorMultiplier: 10, + timeout: 120000, + }, + roles: { allow: ['reporting_user'] }, + }); + }); + it(`context {"dev":false,"dist":true} produces correct config`, () => { + expect(ConfigSchema.validate({}, { dev: false, dist: true })).toMatchObject({ + capture: { + browser: { + autoDownload: false, + chromium: { disableSandbox: false, inspect: false, proxy: { enabled: false } }, + type: 'chromium', + }, + loadDelay: 3000, + maxAttempts: 3, + networkPolicy: { + enabled: true, + rules: [ + { allow: true, host: undefined, protocol: 'http:' }, + { allow: true, host: undefined, protocol: 'https:' }, + { allow: true, host: undefined, protocol: 'ws:' }, + { allow: true, host: undefined, protocol: 'wss:' }, + { allow: true, host: undefined, protocol: 'data:' }, + { allow: false, host: undefined, protocol: undefined }, + ], + }, + viewport: { height: 1200, width: 1950 }, + zoom: 2, + }, + csv: { + checkForFormulas: true, + enablePanelActionDownload: true, + maxSizeBytes: 10485760, + scroll: { duration: '30s', size: 500 }, + }, + index: '.reporting', + kibanaServer: {}, + poll: { + jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, + jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, + }, + queue: { + indexInterval: 'week', + pollEnabled: true, + pollInterval: 3000, + pollIntervalErrorMultiplier: 10, + timeout: 120000, + }, + roles: { allow: ['reporting_user'] }, + }); + }); +}); diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts new file mode 100644 index 0000000000000..0058b7a5096f0 --- /dev/null +++ b/x-pack/plugins/reporting/server/config/schema.ts @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import moment from 'moment'; + +const KibanaServerSchema = schema.object({ + hostname: schema.maybe( + schema.string({ + validate(value) { + if (value === '0') { + return 'must not be "0" for the headless browser to correctly resolve the host'; + } + }, + hostname: true, + }) + ), + port: schema.maybe(schema.number()), + protocol: schema.maybe( + schema.string({ + validate(value) { + if (!/^https?$/.test(value)) { + return 'must be "http" or "https"'; + } + }, + }) + ), +}); + +const QueueSchema = schema.object({ + indexInterval: schema.string({ defaultValue: 'week' }), + pollEnabled: schema.boolean({ defaultValue: true }), + pollInterval: schema.number({ defaultValue: 3000 }), + pollIntervalErrorMultiplier: schema.number({ defaultValue: 10 }), + timeout: schema.number({ defaultValue: moment.duration(2, 'm').asMilliseconds() }), +}); + +const RulesSchema = schema.object({ + allow: schema.boolean(), + host: schema.maybe(schema.string()), + protocol: schema.maybe(schema.string()), +}); + +const CaptureSchema = schema.object({ + timeouts: schema.object({ + openUrl: schema.number({ defaultValue: 30000 }), + waitForElements: schema.number({ defaultValue: 30000 }), + renderComplete: schema.number({ defaultValue: 30000 }), + }), + networkPolicy: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + rules: schema.arrayOf(RulesSchema, { + defaultValue: [ + { host: undefined, allow: true, protocol: 'http:' }, + { host: undefined, allow: true, protocol: 'https:' }, + { host: undefined, allow: true, protocol: 'ws:' }, + { host: undefined, allow: true, protocol: 'wss:' }, + { host: undefined, allow: true, protocol: 'data:' }, + { host: undefined, allow: false, protocol: undefined }, // Default action is to deny! + ], + }), + }), + zoom: schema.number({ defaultValue: 2 }), + viewport: schema.object({ + width: schema.number({ defaultValue: 1950 }), + height: schema.number({ defaultValue: 1200 }), + }), + loadDelay: schema.number({ + defaultValue: moment.duration(3, 's').asMilliseconds(), + }), // TODO: use schema.duration + browser: schema.object({ + autoDownload: schema.conditional( + schema.contextRef('dist'), + true, + schema.boolean({ defaultValue: false }), + schema.boolean({ defaultValue: true }) + ), + chromium: schema.object({ + inspect: schema.conditional( + schema.contextRef('dist'), + true, + schema.boolean({ defaultValue: false }), + schema.maybe(schema.never()) + ), + disableSandbox: schema.boolean({ defaultValue: false }), + proxy: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + server: schema.conditional( + schema.siblingRef('enabled'), + true, + schema.uri({ scheme: ['http', 'https'] }), + schema.maybe(schema.never()) + ), + bypass: schema.conditional( + schema.siblingRef('enabled'), + true, + schema.arrayOf(schema.string({ hostname: true })), + schema.maybe(schema.never()) + ), + }), + userDataDir: schema.maybe(schema.string()), // FIXME unused? + }), + type: schema.string({ defaultValue: 'chromium' }), + }), + maxAttempts: schema.conditional( + schema.contextRef('dist'), + true, + schema.number({ defaultValue: 3 }), + schema.number({ defaultValue: 1 }) + ), +}); + +const CsvSchema = schema.object({ + checkForFormulas: schema.boolean({ defaultValue: true }), + enablePanelActionDownload: schema.boolean({ defaultValue: true }), + maxSizeBytes: schema.number({ + defaultValue: 1024 * 1024 * 10, // 10MB + }), // TODO: use schema.byteSize + scroll: schema.object({ + duration: schema.string({ + defaultValue: '30s', + validate(value) { + if (!/^[0-9]+(d|h|m|s|ms|micros|nanos)$/.test(value)) { + return 'must be a duration string'; + } + }, + }), + size: schema.number({ defaultValue: 500 }), + }), +}); + +const EncryptionKeySchema = schema.conditional( + schema.contextRef('dist'), + true, + schema.maybe(schema.string({ minLength: 32 })), + schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) }) +); + +const RolesSchema = schema.object({ + allow: schema.arrayOf(schema.string(), { defaultValue: ['reporting_user'] }), +}); + +const IndexSchema = schema.string({ defaultValue: '.reporting' }); + +const PollSchema = schema.object({ + jobCompletionNotifier: schema.object({ + interval: schema.number({ + defaultValue: moment.duration(10, 's').asMilliseconds(), + }), // TODO: use schema.duration + intervalErrorMultiplier: schema.number({ defaultValue: 5 }), + }), + jobsRefresh: schema.object({ + interval: schema.number({ + defaultValue: moment.duration(5, 's').asMilliseconds(), + }), // TODO: use schema.duration + intervalErrorMultiplier: schema.number({ defaultValue: 5 }), + }), +}); + +export const ConfigSchema = schema.object({ + kibanaServer: KibanaServerSchema, + queue: QueueSchema, + capture: CaptureSchema, + csv: CsvSchema, + encryptionKey: EncryptionKeySchema, + roles: RolesSchema, + index: IndexSchema, + poll: PollSchema, +}); + +export type ConfigType = TypeOf; diff --git a/x-pack/plugins/reporting/server/index.ts b/x-pack/plugins/reporting/server/index.ts new file mode 100644 index 0000000000000..2b1844cf2e10e --- /dev/null +++ b/x-pack/plugins/reporting/server/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/server'; +import { ReportingPlugin } from './plugin'; + +export { config, ConfigSchema } from './config'; +export { ConfigType, PluginsSetup } from './plugin'; + +export const plugin = (initializerContext: PluginInitializerContext) => + new ReportingPlugin(initializerContext); diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts new file mode 100644 index 0000000000000..53d821cffbb1f --- /dev/null +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -0,0 +1,38 @@ +/* + * 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 { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { CoreSetup, Logger, Plugin, PluginInitializerContext } from '../../../../src/core/server'; +import { ConfigType, createConfig$ } from './config'; + +export interface PluginsSetup { + /** @deprecated */ + __legacy: { + config$: Observable; + }; +} + +export class ReportingPlugin implements Plugin { + private readonly log: Logger; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.log = this.initializerContext.logger.get(); + } + + public async setup(core: CoreSetup): Promise { + return { + __legacy: { + config$: createConfig$(core, this.initializerContext, this.log).pipe(first()), + }, + }; + } + + public start() {} + public stop() {} +} + +export { ConfigType }; From e3431752f3a45ce41100201f7c447aa1fb65d439 Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Mon, 23 Mar 2020 18:09:30 -0500 Subject: [PATCH 050/179] [SIEM] Move Timeline Template field to first step of rule creation (#60840) * Move timeline template to Define step of Rule creation This required a refactor/simplification of the step_define_rule logic to make things work. In retrospect I think that the issue was we were not handling incoming `defaultValues` props well, which was causing local component state to be lost. Now that we're doing a merge and removed a few unneeded local useStates, things are a) working and b) cleaner * Fix Rule details/edit view with updated data We need to fix the other side of the equation to get these to work: the timeline data was moved to a different step during creation, but when viewing on the frontend we split the rule data back into the separate "steps." * Remove unused import * Fix bug in formatDefineStepData I neglected to pass through index in a previous commit. * Update tests now that timeline has movied to a different step * Fix more tests * Update StepRuleDescription snapshots * Fix cypress Rule Creation test Timeline template moved, and so tests broke. * Add unit tests for filterRuleFieldsForType --- .../signal_detection_rules.spec.ts | 10 +- .../siem/cypress/screens/rule_details.ts | 12 +- .../rules/all/__mocks__/mock.ts | 8 +- .../__snapshots__/index.test.tsx.snap | 34 +- .../description_step/index.test.tsx | 9 +- .../step_about_rule/default_value.ts | 5 - .../components/step_about_rule/index.tsx | 10 - .../components/step_about_rule/schema.tsx | 15 - .../components/step_define_rule/index.tsx | 75 ++-- .../components/step_define_rule/schema.tsx | 15 + .../rules/create/helpers.test.ts | 320 +++++++++--------- .../detection_engine/rules/create/helpers.ts | 65 ++-- .../detection_engine/rules/helpers.test.tsx | 36 +- .../pages/detection_engine/rules/helpers.tsx | 36 +- .../pages/detection_engine/rules/types.ts | 6 +- 15 files changed, 308 insertions(+), 348 deletions(-) diff --git a/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts index ce73fe1b7c2a5..70e4fb052e172 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts @@ -13,10 +13,10 @@ import { ABOUT_SEVERITY, ABOUT_STEP, ABOUT_TAGS, - ABOUT_TIMELINE, ABOUT_URLS, DEFINITION_CUSTOM_QUERY, DEFINITION_INDEX_PATTERNS, + DEFINITION_TIMELINE, DEFINITION_STEP, RULE_NAME_HEADER, SCHEDULE_LOOPBACK, @@ -170,10 +170,6 @@ describe('Signal detection rules', () => { .eq(ABOUT_RISK) .invoke('text') .should('eql', newRule.riskScore); - cy.get(ABOUT_STEP) - .eq(ABOUT_TIMELINE) - .invoke('text') - .should('eql', 'Default blank timeline'); cy.get(ABOUT_STEP) .eq(ABOUT_URLS) .invoke('text') @@ -202,6 +198,10 @@ describe('Signal detection rules', () => { .eq(DEFINITION_CUSTOM_QUERY) .invoke('text') .should('eql', `${newRule.customQuery} `); + cy.get(DEFINITION_STEP) + .eq(DEFINITION_TIMELINE) + .invoke('text') + .should('eql', 'Default blank timeline'); cy.get(SCHEDULE_STEP) .eq(SCHEDULE_RUNS) diff --git a/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts b/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts index 6c16735ba5f24..06e535b37708c 100644 --- a/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts +++ b/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export const ABOUT_FALSE_POSITIVES = 4; +export const ABOUT_FALSE_POSITIVES = 3; -export const ABOUT_MITRE = 5; +export const ABOUT_MITRE = 4; export const ABOUT_RULE_DESCRIPTION = '[data-test-subj=stepAboutRuleDetailsToggleDescriptionText]'; @@ -16,14 +16,14 @@ export const ABOUT_SEVERITY = 0; export const ABOUT_STEP = '[data-test-subj="aboutRule"] .euiDescriptionList__description'; -export const ABOUT_TAGS = 6; +export const ABOUT_TAGS = 5; -export const ABOUT_TIMELINE = 2; - -export const ABOUT_URLS = 3; +export const ABOUT_URLS = 2; export const DEFINITION_CUSTOM_QUERY = 1; +export const DEFINITION_TIMELINE = 3; + export const DEFINITION_INDEX_PATTERNS = '[data-test-subj=definitionRule] [data-test-subj="listItemColumnStepRuleDescription"] .euiDescriptionList__description .euiBadge__text'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts index 011a2614c1af9..a6aefefedd5c3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -155,10 +155,6 @@ export const mockAboutStepRule = (isNew = false): AboutStepRule => ({ references: ['www.test.co'], falsePositives: ['test'], tags: ['tag1', 'tag2'], - timeline: { - id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - title: 'Titled timeline', - }, threat: [ { framework: 'mockFramework', @@ -186,6 +182,10 @@ export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ machineLearningJobId: '', index: ['filebeat-'], queryBar: mockQueryBar, + timeline: { + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + title: 'Titled timeline', + }, }); export const mockScheduleStepRule = (isNew = false, enabled = false): ScheduleStepRule => ({ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap index 4d416e70a096c..9a534297e5e29 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap @@ -27,21 +27,6 @@ exports[`description_step StepRuleDescriptionComponent renders correctly against "description": 21, "title": "Risk score", }, - Object { - "description": "Titled timeline", - "title": "Timeline template", - }, - ] - } - /> -
- - , "title": "Reference URLs", }, + ] + } + /> + + + { test('returns expected ListItems array when given valid inputs', () => { const result: ListItems[] = buildListItems(mockAboutStep, schema, mockFilterManager); - expect(result.length).toEqual(10); + expect(result.length).toEqual(9); }); }); @@ -431,10 +431,11 @@ describe('description_step', () => { describe('timeline', () => { test('returns timeline title if one exists', () => { + const mockDefineStep = mockDefineStepRule(); const result: ListItems[] = getDescriptionItem( 'timeline', 'Timeline label', - mockAboutStep, + mockDefineStep, mockFilterManager ); @@ -444,7 +445,7 @@ describe('description_step', () => { test('returns default timeline title if none exists', () => { const mockStep = { - ...mockAboutStep, + ...mockDefineStepRule(), timeline: { id: '12345', }, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts index 417133f230610..52b0038507b59 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts @@ -5,7 +5,6 @@ */ import { AboutStepRule } from '../../types'; -import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/translations'; export const threatDefault = [ { @@ -24,10 +23,6 @@ export const stepAboutDefaultValue: AboutStepRule = { references: [''], falsePositives: [''], tags: [], - timeline: { - id: null, - title: DEFAULT_TIMELINE_TITLE, - }, threat: threatDefault, note: '', }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx index bfb123f3f3204..58b6ca54f5bbd 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx @@ -37,7 +37,6 @@ import { stepAboutDefaultValue } from './default_value'; import { isUrlInvalid } from './helpers'; import { schema } from './schema'; import * as I18n from './translations'; -import { PickTimeline } from '../pick_timeline'; import { StepContentWrapper } from '../step_content_wrapper'; import { MarkdownEditorForm } from '../../../../../components/markdown_editor/form'; @@ -216,15 +215,6 @@ const StepAboutRuleComponent: FC = ({ buttonContent={AdvancedSettingsAccordionButton} > - { - if (defaultValues != null) { - return { - ...defaultValues, - isNew: false, - }; - } else { - return { - ...stepDefineDefaultValue, - index: indicesConfig != null ? indicesConfig : [], - }; - } -}; - const StepDefineRuleComponent: FC = ({ addPadding = false, defaultValues, @@ -106,18 +94,16 @@ const StepDefineRuleComponent: FC = ({ }) => { const mlCapabilities = useContext(MlCapabilitiesContext); const [openTimelineSearch, setOpenTimelineSearch] = useState(false); - const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(false); + const [indexModified, setIndexModified] = useState(false); const [localIsMlRule, setIsMlRule] = useState(false); const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); - const [mylocalIndicesConfig, setMyLocalIndicesConfig] = useState( - defaultValues != null ? defaultValues.index : indicesConfig ?? [] - ); + const [myStepData, setMyStepData] = useState({ + ...stepDefineDefaultValue, + index: indicesConfig ?? [], + }); const [ { browserFields, indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, - ] = useFetchIndexPatterns(mylocalIndicesConfig); - const [myStepData, setMyStepData] = useState( - getStepDefaultValue(indicesConfig, null) - ); + ] = useFetchIndexPatterns(myStepData.index); const { form } = useForm({ defaultValue: myStepData, @@ -138,15 +124,13 @@ const StepDefineRuleComponent: FC = ({ }, [form]); useEffect(() => { - if (indicesConfig != null && defaultValues != null) { - const myDefaultValues = getStepDefaultValue(indicesConfig, defaultValues); - if (!deepEqual(myDefaultValues, myStepData)) { - setMyStepData(myDefaultValues); - setLocalUseIndicesConfig(deepEqual(myDefaultValues.index, indicesConfig)); - setFieldValue(form, schema, myDefaultValues); - } + const { isNew, ...values } = myStepData; + if (defaultValues != null && !deepEqual(values, defaultValues)) { + const newValues = { ...values, ...defaultValues, isNew: false }; + setMyStepData(newValues); + setFieldValue(form, schema, newValues); } - }, [defaultValues, indicesConfig]); + }, [defaultValues, setMyStepData, setFieldValue]); useEffect(() => { if (setForm != null) { @@ -195,7 +179,7 @@ const StepDefineRuleComponent: FC = ({ path="index" config={{ ...schema.index, - labelAppend: !localUseIndicesConfig ? ( + labelAppend: indexModified ? ( {i18n.RESET_DEFAULT_INDEX} @@ -253,17 +237,22 @@ const StepDefineRuleComponent: FC = ({ /> + {({ index, ruleType }) => { if (index != null) { - if (deepEqual(index, indicesConfig) && !localUseIndicesConfig) { - setLocalUseIndicesConfig(true); - } - if (!deepEqual(index, indicesConfig) && localUseIndicesConfig) { - setLocalUseIndicesConfig(false); - } - if (index != null && !isEmpty(index) && !deepEqual(index, mylocalIndicesConfig)) { - setMyLocalIndicesConfig(index); + if (deepEqual(index, indicesConfig) && indexModified) { + setIndexModified(false); + } else if (!deepEqual(index, indicesConfig) && !indexModified) { + setIndexModified(true); } } diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx index bcfcd4f4ee09d..271c8fabed3a5 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx @@ -158,4 +158,19 @@ export const schema: FormSchema = { }, ], }, + timeline: { + label: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTimelineTemplateLabel', + { + defaultMessage: 'Timeline template', + } + ), + helpText: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTimelineTemplateHelpText', + { + defaultMessage: + 'Select an existing timeline to use as a template when investigating generated signals.', + } + ), + }, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts index ea6b02924cb3e..dc0459c54adb0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts @@ -19,6 +19,7 @@ import { formatScheduleStepData, formatAboutStepData, formatRule, + filterRuleFieldsForType, } from './helpers'; import { mockDefineStepRule, @@ -88,6 +89,8 @@ describe('helpers', () => { saved_id: 'test123', index: ['filebeat-'], type: 'saved_query', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -109,6 +112,119 @@ describe('helpers', () => { index: ['filebeat-'], saved_id: '', type: 'query', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Titled timeline', + }; + + expect(result).toEqual(expected); + }); + + test('returns formatted object without timeline_id and timeline_title if timeline.id is null', () => { + const mockStepData = { + ...mockData, + }; + delete mockStepData.timeline.id; + + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + language: 'kuery', + filters: mockQueryBar.filters, + query: 'test query', + index: ['filebeat-'], + saved_id: 'test123', + type: 'saved_query', + }; + + expect(result).toEqual(expected); + }); + + test('returns formatted object with timeline_id and timeline_title if timeline.id is "', () => { + const mockStepData = { + ...mockData, + timeline: { + ...mockData.timeline, + id: '', + }, + }; + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + language: 'kuery', + filters: mockQueryBar.filters, + query: 'test query', + index: ['filebeat-'], + saved_id: 'test123', + type: 'saved_query', + timeline_id: '', + timeline_title: 'Titled timeline', + }; + + expect(result).toEqual(expected); + }); + + test('returns formatted object without timeline_id and timeline_title if timeline.title is null', () => { + const mockStepData = { + ...mockData, + timeline: { + ...mockData.timeline, + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + }, + }; + delete mockStepData.timeline.title; + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + language: 'kuery', + filters: mockQueryBar.filters, + query: 'test query', + index: ['filebeat-'], + saved_id: 'test123', + type: 'saved_query', + }; + + expect(result).toEqual(expected); + }); + + test('returns formatted object with timeline_id and timeline_title if timeline.title is "', () => { + const mockStepData = { + ...mockData, + timeline: { + ...mockData.timeline, + title: '', + }, + }; + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + language: 'kuery', + filters: mockQueryBar.filters, + query: 'test query', + index: ['filebeat-'], + saved_id: 'test123', + type: 'saved_query', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: '', + }; + + expect(result).toEqual(expected); + }); + + test('returns ML fields if type is machine_learning', () => { + const mockStepData: DefineStepRule = { + ...mockData, + ruleType: 'machine_learning', + anomalyThreshold: 44, + machineLearningJobId: 'some_jobert_id', + }; + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + type: 'machine_learning', + anomaly_threshold: 44, + machine_learning_job_id: 'some_jobert_id', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -249,8 +365,6 @@ describe('helpers', () => { ], }, ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -289,8 +403,6 @@ describe('helpers', () => { ], }, ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -327,160 +439,6 @@ describe('helpers', () => { ], }, ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: 'Titled timeline', - }; - - expect(result).toEqual(expected); - }); - - test('returns formatted object without timeline_id and timeline_title if timeline.id is null', () => { - const mockStepData = { - ...mockData, - }; - delete mockStepData.timeline.id; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { - description: '24/7', - false_positives: ['test'], - name: 'Query with rule-id', - note: '# this is some markdown documentation', - references: ['www.test.co'], - risk_score: 21, - severity: 'low', - tags: ['tag1', 'tag2'], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: '1234', - name: 'tactic1', - reference: 'reference1', - }, - technique: [ - { - id: '456', - name: 'technique1', - reference: 'technique reference', - }, - ], - }, - ], - }; - - expect(result).toEqual(expected); - }); - - test('returns formatted object with timeline_id and timeline_title if timeline.id is "', () => { - const mockStepData = { - ...mockData, - timeline: { - ...mockData.timeline, - id: '', - }, - }; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { - description: '24/7', - false_positives: ['test'], - name: 'Query with rule-id', - note: '# this is some markdown documentation', - references: ['www.test.co'], - risk_score: 21, - severity: 'low', - tags: ['tag1', 'tag2'], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: '1234', - name: 'tactic1', - reference: 'reference1', - }, - technique: [ - { - id: '456', - name: 'technique1', - reference: 'technique reference', - }, - ], - }, - ], - timeline_id: '', - timeline_title: 'Titled timeline', - }; - - expect(result).toEqual(expected); - }); - - test('returns formatted object without timeline_id and timeline_title if timeline.title is null', () => { - const mockStepData = { - ...mockData, - timeline: { - ...mockData.timeline, - id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - }, - }; - delete mockStepData.timeline.title; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { - description: '24/7', - false_positives: ['test'], - name: 'Query with rule-id', - note: '# this is some markdown documentation', - references: ['www.test.co'], - risk_score: 21, - severity: 'low', - tags: ['tag1', 'tag2'], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: '1234', - name: 'tactic1', - reference: 'reference1', - }, - technique: [ - { - id: '456', - name: 'technique1', - reference: 'technique reference', - }, - ], - }, - ], - }; - - expect(result).toEqual(expected); - }); - - test('returns formatted object with timeline_id and timeline_title if timeline.title is "', () => { - const mockStepData = { - ...mockData, - timeline: { - id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - title: '', - }, - }; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { - description: '24/7', - false_positives: ['test'], - name: 'Query with rule-id', - note: '# this is some markdown documentation', - references: ['www.test.co'], - risk_score: 21, - severity: 'low', - tags: ['tag1', 'tag2'], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { id: '1234', name: 'tactic1', reference: 'reference1' }, - technique: [{ id: '456', name: 'technique1', reference: 'technique reference' }], - }, - ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: '', }; expect(result).toEqual(expected); @@ -539,8 +497,6 @@ describe('helpers', () => { technique: [{ id: '456', name: 'technique1', reference: 'technique reference' }], }, ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -583,4 +539,48 @@ describe('helpers', () => { expect(result.id).toBeUndefined(); }); }); + + describe('filterRuleFieldsForType', () => { + let fields: DefineStepRule; + + beforeEach(() => { + fields = mockDefineStepRule(); + }); + + it('removes query fields if the type is machine learning', () => { + const result = filterRuleFieldsForType(fields, 'machine_learning'); + expect(result).not.toHaveProperty('index'); + expect(result).not.toHaveProperty('queryBar'); + }); + + it('leaves ML fields if the type is machine learning', () => { + const result = filterRuleFieldsForType(fields, 'machine_learning'); + expect(result).toHaveProperty('anomalyThreshold'); + expect(result).toHaveProperty('machineLearningJobId'); + }); + + it('leaves arbitrary fields if the type is machine learning', () => { + const result = filterRuleFieldsForType(fields, 'machine_learning'); + expect(result).toHaveProperty('timeline'); + expect(result).toHaveProperty('ruleType'); + }); + + it('removes ML fields if the type is not machine learning', () => { + const result = filterRuleFieldsForType(fields, 'query'); + expect(result).not.toHaveProperty('anomalyThreshold'); + expect(result).not.toHaveProperty('machineLearningJobId'); + }); + + it('leaves query fields if the type is query', () => { + const result = filterRuleFieldsForType(fields, 'query'); + expect(result).toHaveProperty('index'); + expect(result).toHaveProperty('queryBar'); + }); + + it('leaves arbitrary fields if the type is query', () => { + const result = filterRuleFieldsForType(fields, 'query'); + expect(result).toHaveProperty('timeline'); + expect(result).toHaveProperty('ruleType'); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts index 1f3379bf681bb..f8900e6a1129e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts @@ -64,27 +64,35 @@ export const filterRuleFieldsForType = (fields: T, type: R export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStepRuleJson => { const ruleFields = filterRuleFieldsForType(defineStepData, defineStepData.ruleType); + const { ruleType, timeline } = ruleFields; + const baseFields = { + type: ruleType, + ...(timeline.id != null && + timeline.title != null && { + timeline_id: timeline.id, + timeline_title: timeline.title, + }), + }; - if (isMlFields(ruleFields)) { - const { anomalyThreshold, machineLearningJobId, isNew, ruleType, ...rest } = ruleFields; - return { - ...rest, - type: ruleType, - anomaly_threshold: anomalyThreshold, - machine_learning_job_id: machineLearningJobId, - }; - } else { - const { queryBar, isNew, ruleType, ...rest } = ruleFields; - return { - ...rest, - type: ruleType, - filters: queryBar?.filters, - language: queryBar?.query?.language, - query: queryBar?.query?.query as string, - saved_id: queryBar?.saved_id, - ...(ruleType === 'query' && queryBar?.saved_id ? { type: 'saved_query' as RuleType } : {}), - }; - } + const typeFields = isMlFields(ruleFields) + ? { + anomaly_threshold: ruleFields.anomalyThreshold, + machine_learning_job_id: ruleFields.machineLearningJobId, + } + : { + index: ruleFields.index, + filters: ruleFields.queryBar?.filters, + language: ruleFields.queryBar?.query?.language, + query: ruleFields.queryBar?.query?.query as string, + saved_id: ruleFields.queryBar?.saved_id, + ...(ruleType === 'query' && + ruleFields.queryBar?.saved_id && { type: 'saved_query' as RuleType }), + }; + + return { + ...baseFields, + ...typeFields, + }; }; export const formatScheduleStepData = (scheduleData: ScheduleStepRule): ScheduleStepRuleJson => { @@ -108,26 +116,11 @@ export const formatScheduleStepData = (scheduleData: ScheduleStepRule): Schedule }; export const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRuleJson => { - const { - falsePositives, - references, - riskScore, - threat, - timeline, - isNew, - note, - ...rest - } = aboutStepData; + const { falsePositives, references, riskScore, threat, isNew, note, ...rest } = aboutStepData; return { false_positives: falsePositives.filter(item => !isEmpty(item)), references: references.filter(item => !isEmpty(item)), risk_score: riskScore, - ...(timeline.id != null && timeline.title != null - ? { - timeline_id: timeline.id, - timeline_title: timeline.title, - } - : {}), threat: threat .filter(singleThreat => singleThreat.tactic.name !== 'none') .map(singleThreat => ({ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx index ee43ae5f1d6e2..3224c605192e6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx @@ -65,6 +65,10 @@ describe('rule helpers', () => { ], saved_id: 'test123', }, + timeline: { + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + title: 'Titled timeline', + }, }; const aboutRuleStepData = { description: '24/7', @@ -93,10 +97,6 @@ describe('rule helpers', () => { ], }, ], - timeline: { - id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - title: 'Titled timeline', - }, }; const scheduleRuleStepData = { enabled: true, from: '0s', interval: '5m', isNew: false }; const aboutRuleDataDetailsData = { @@ -112,16 +112,6 @@ describe('rule helpers', () => { }); describe('getAboutStepsData', () => { - test('returns timeline id and title of null if they do not exist on rule', () => { - const mockedRule = mockRuleWithEverything('test-id'); - delete mockedRule.timeline_id; - delete mockedRule.timeline_title; - const result: AboutStepRule = getAboutStepsData(mockedRule, false); - - expect(result.timeline.id).toBeNull(); - expect(result.timeline.title).toBeNull(); - }); - test('returns name, description, and note as empty string if detailsView is true', () => { const result: AboutStepRule = getAboutStepsData(mockRuleWithEverything('test-id'), true); @@ -195,6 +185,10 @@ describe('rule helpers', () => { filters: [], saved_id: "Garrett's IP", }, + timeline: { + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + title: 'Untitled timeline', + }, }; expect(result).toEqual(expected); @@ -220,10 +214,24 @@ describe('rule helpers', () => { filters: [], saved_id: undefined, }, + timeline: { + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + title: 'Untitled timeline', + }, }; expect(result).toEqual(expected); }); + + test('returns timeline id and title of null if they do not exist on rule', () => { + const mockedRule = mockRuleWithEverything('test-id'); + delete mockedRule.timeline_id; + delete mockedRule.timeline_title; + const result: DefineStepRule = getDefineStepsData(mockedRule); + + expect(result.timeline.id).toBeNull(); + expect(result.timeline.title).toBeNull(); + }); }); describe('getHumanizedDuration', () => { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx index e59ca5e7e14e5..2ace154482a27 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx @@ -42,20 +42,22 @@ export const getStepsData = ({ return { aboutRuleData, modifiedAboutRuleDetailsData, defineRuleData, scheduleRuleData }; }; -export const getDefineStepsData = (rule: Rule): DefineStepRule => { - return { - isNew: false, - ruleType: rule.type, - anomalyThreshold: rule.anomaly_threshold ?? 50, - machineLearningJobId: rule.machine_learning_job_id ?? '', - index: rule.index ?? [], - queryBar: { - query: { query: rule.query ?? '', language: rule.language ?? '' }, - filters: (rule.filters ?? []) as Filter[], - saved_id: rule.saved_id, - }, - }; -}; +export const getDefineStepsData = (rule: Rule): DefineStepRule => ({ + isNew: false, + ruleType: rule.type, + anomalyThreshold: rule.anomaly_threshold ?? 50, + machineLearningJobId: rule.machine_learning_job_id ?? '', + index: rule.index ?? [], + queryBar: { + query: { query: rule.query ?? '', language: rule.language ?? '' }, + filters: (rule.filters ?? []) as Filter[], + saved_id: rule.saved_id, + }, + timeline: { + id: rule.timeline_id ?? null, + title: rule.timeline_title ?? null, + }, +}); export const getScheduleStepsData = (rule: Rule): ScheduleStepRule => { const { enabled, interval, from } = rule; @@ -94,8 +96,6 @@ export const getAboutStepsData = (rule: Rule, detailsView: boolean): AboutStepRu risk_score: riskScore, tags, threat, - timeline_id: timelineId, - timeline_title: timelineTitle, } = rule; return { @@ -109,10 +109,6 @@ export const getAboutStepsData = (rule: Rule, detailsView: boolean): AboutStepRu riskScore, falsePositives, threat: threat as IMitreEnterpriseAttack[], - timeline: { - id: timelineId ?? null, - title: timelineTitle ?? null, - }, }; }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index 447b5dc6325ee..d4caa4639f338 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts @@ -57,7 +57,6 @@ export interface AboutStepRule extends StepRuleData { references: string[]; falsePositives: string[]; tags: string[]; - timeline: FieldValueTimeline; threat: IMitreEnterpriseAttack[]; note: string; } @@ -73,6 +72,7 @@ export interface DefineStepRule extends StepRuleData { machineLearningJobId: string; queryBar: FieldValueQueryBar; ruleType: RuleType; + timeline: FieldValueTimeline; } export interface ScheduleStepRule extends StepRuleData { @@ -90,6 +90,8 @@ export interface DefineStepRuleJson { saved_id?: string; query?: string; language?: string; + timeline_id?: string; + timeline_title?: string; type: RuleType; } @@ -101,8 +103,6 @@ export interface AboutStepRuleJson { references: string[]; false_positives: string[]; tags: string[]; - timeline_id?: string; - timeline_title?: string; threat: IMitreEnterpriseAttack[]; note?: string; } From f32a8483bcc5d88b56a9a9aa03466aa2d7457555 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Mon, 23 Mar 2020 16:10:12 -0700 Subject: [PATCH 051/179] Create Painless Lab app (#57538) * Create Painless Playground app (#54578) * Replace heart script with smiley face script. (#57755) * Rename Painless Playground -> Painless Lab. (#57545) * Fix i18n namespace. * Improve smiley face proportions. - Add def keyword to Painless spec. - Temporarily fix broken highlighting. - Add small padding to main controls. * [Painless Lab] Minor Fixes (#58135) * Code restructure, improve types, add plugin id, introduced hook Moved the code execution hook to a custom hook outside of main, also chaining off promise to avoid lower level handling of sequencing. * Re-instated formatting code To improve DX the execution error response from the painless API was massaged to a more reader friendly state, only giving non-repeating information. Currently it is hard to determine the line and character information from the painless endpoint. If the user wishes to see this raw information it will be available in the API response flyout. * Remove leading new line in default script * Remove registration of feature flag * Fix types * Restore previous auto-submit request behaviour * Remove use of null and remove old comment Stick with "undefined" as the designation for something not existing. * [Painless Lab] NP migration (#59794) * Fix sample document editor. * [Painless Lab] Fix float -> integer coercion bug (#60201) * Clarify data and persistence flow. Fix floating point precision bug. * Send a string to API and ES client instead of an object. * Rename helpers lib to format. Add tests for formatRequestPayload. * Add query parameter to score context (#60414) * Fix typo and i18n * Make state init lazy Otherwise we are needlessly reading and JSON.parse'ing on every state update * Support the query parameter in requests to Painless * Fix borked i18n * Fix i18n * Another i18n issue * [Painless] Minor state update model refactor (#60532) * Fix typo and i18n * Make state init lazy Otherwise we are needlessly reading and JSON.parse'ing on every state update * Support the query parameter in requests to Painless * WiP on state refactor * Some cleanup after manual testing * Fix types and i18n * Fix i18n in context_tab * i18n * [Painless] Language Service (#60612) * Added language service * Use the correct monaco instance and add wordwise operations * Remove plugin context initializer for now * [Painless] Replace hard-coded links (#60603) * Replace hard-coded links Also remove all props from Main component * Pass the new links object to the request flyout too * Link directly to painless execute API's contexts * Remove responsive stacking from tabs with icons in them. * Resize Painless Lab bottom bar to accommodate nav drawer width (#60833) * Validate Painless Lab index field (#60841) * Make JSON format of parameters field more prominent. Set default parameters to provide an example to users. * Set default document to provide an example to users. * Simplify context's updateState interface. * Refactor store and context file organization. - Remove common directory, move constants and types files to root. - Move initialState into context file, where it's being used. * Add validation for index input. * Create context directory. * Fix bottom bar z-index. * Position flyout help link so it's bottom-aligned with the title and farther from the close button. Co-authored-by: Matthias Wilhelm Co-authored-by: Jean-Louis Leysens Co-authored-by: Elastic Machine Co-authored-by: Alison Goryachev --- .github/CODEOWNERS | 1 + packages/kbn-ui-shared-deps/monaco.ts | 2 + src/plugins/dev_tools/public/plugin.ts | 2 + x-pack/.i18nrc.json | 1 + .../plugins/painless_lab/common/constants.ts | 15 ++ x-pack/plugins/painless_lab/kibana.json | 16 ++ .../public/application/components/editor.tsx | 35 +++ .../public/application/components/main.tsx | 94 ++++++++ .../application/components/main_controls.tsx | 145 +++++++++++++ .../components/output_pane/context_tab.tsx | 202 +++++++++++++++++ .../components/output_pane/index.ts | 7 + .../components/output_pane/output_pane.tsx | 79 +++++++ .../components/output_pane/output_tab.tsx | 26 +++ .../components/output_pane/parameters_tab.tsx | 87 ++++++++ .../application/components/request_flyout.tsx | 98 +++++++++ .../public/application/constants.tsx | 135 ++++++++++++ .../public/application/context/context.tsx | 95 ++++++++ .../public/application/context/index.tsx | 7 + .../application/context/initial_payload.ts | 22 ++ .../public/application/hooks/index.ts | 7 + .../application/hooks/use_submit_code.ts | 64 ++++++ .../painless_lab/public/application/index.tsx | 46 ++++ .../lib/__snapshots__/format.test.ts.snap | 205 ++++++++++++++++++ .../public/application/lib/format.test.ts | 86 ++++++++ .../public/application/lib/format.ts | 117 ++++++++++ .../painless_lab/public/application/types.ts | 58 +++++ x-pack/plugins/painless_lab/public/index.scss | 1 + x-pack/plugins/painless_lab/public/index.ts | 12 + .../plugins/painless_lab/public/lib/index.ts | 7 + .../public/lib/monaco_painless_lang.ts | 174 +++++++++++++++ x-pack/plugins/painless_lab/public/links.ts | 20 ++ x-pack/plugins/painless_lab/public/plugin.tsx | 114 ++++++++++ .../painless_lab/public/services/index.ts | 7 + .../public/services/language_service.ts | 45 ++++ .../painless_lab/public/styles/_index.scss | 58 +++++ x-pack/plugins/painless_lab/public/types.ts | 15 ++ x-pack/plugins/painless_lab/server/index.ts | 11 + .../plugins/painless_lab/server/lib/index.ts | 7 + .../painless_lab/server/lib/is_es_error.ts | 13 ++ x-pack/plugins/painless_lab/server/plugin.ts | 47 ++++ .../painless_lab/server/routes/api/execute.ts | 46 ++++ .../painless_lab/server/routes/api/index.ts | 7 + .../painless_lab/server/services/index.ts | 7 + .../painless_lab/server/services/license.ts | 82 +++++++ x-pack/plugins/painless_lab/server/types.ts | 17 ++ 45 files changed, 2342 insertions(+) create mode 100644 x-pack/plugins/painless_lab/common/constants.ts create mode 100644 x-pack/plugins/painless_lab/kibana.json create mode 100644 x-pack/plugins/painless_lab/public/application/components/editor.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/main.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/main_controls.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/output_pane/index.ts create mode 100644 x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/output_pane/output_tab.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/output_pane/parameters_tab.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/request_flyout.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/constants.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/context/context.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/context/index.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/context/initial_payload.ts create mode 100644 x-pack/plugins/painless_lab/public/application/hooks/index.ts create mode 100644 x-pack/plugins/painless_lab/public/application/hooks/use_submit_code.ts create mode 100644 x-pack/plugins/painless_lab/public/application/index.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/lib/__snapshots__/format.test.ts.snap create mode 100644 x-pack/plugins/painless_lab/public/application/lib/format.test.ts create mode 100644 x-pack/plugins/painless_lab/public/application/lib/format.ts create mode 100644 x-pack/plugins/painless_lab/public/application/types.ts create mode 100644 x-pack/plugins/painless_lab/public/index.scss create mode 100644 x-pack/plugins/painless_lab/public/index.ts create mode 100644 x-pack/plugins/painless_lab/public/lib/index.ts create mode 100644 x-pack/plugins/painless_lab/public/lib/monaco_painless_lang.ts create mode 100644 x-pack/plugins/painless_lab/public/links.ts create mode 100644 x-pack/plugins/painless_lab/public/plugin.tsx create mode 100644 x-pack/plugins/painless_lab/public/services/index.ts create mode 100644 x-pack/plugins/painless_lab/public/services/language_service.ts create mode 100644 x-pack/plugins/painless_lab/public/styles/_index.scss create mode 100644 x-pack/plugins/painless_lab/public/types.ts create mode 100644 x-pack/plugins/painless_lab/server/index.ts create mode 100644 x-pack/plugins/painless_lab/server/lib/index.ts create mode 100644 x-pack/plugins/painless_lab/server/lib/is_es_error.ts create mode 100644 x-pack/plugins/painless_lab/server/plugin.ts create mode 100644 x-pack/plugins/painless_lab/server/routes/api/execute.ts create mode 100644 x-pack/plugins/painless_lab/server/routes/api/index.ts create mode 100644 x-pack/plugins/painless_lab/server/services/index.ts create mode 100644 x-pack/plugins/painless_lab/server/services/license.ts create mode 100644 x-pack/plugins/painless_lab/server/types.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index df3a56dd35130..2db898fab68bf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -181,6 +181,7 @@ /x-pack/plugins/remote_clusters/ @elastic/es-ui /x-pack/legacy/plugins/rollup/ @elastic/es-ui /x-pack/plugins/searchprofiler/ @elastic/es-ui +/x-pack/plugins/painless_lab/ @elastic/es-ui /x-pack/legacy/plugins/snapshot_restore/ @elastic/es-ui /x-pack/legacy/plugins/upgrade_assistant/ @elastic/es-ui /x-pack/plugins/upgrade_assistant/ @elastic/es-ui diff --git a/packages/kbn-ui-shared-deps/monaco.ts b/packages/kbn-ui-shared-deps/monaco.ts index 570aca86c484c..42801c69a3e2c 100644 --- a/packages/kbn-ui-shared-deps/monaco.ts +++ b/packages/kbn-ui-shared-deps/monaco.ts @@ -25,6 +25,8 @@ import 'monaco-editor/esm/vs/base/worker/defaultWorkerFactory'; import 'monaco-editor/esm/vs/editor/browser/controller/coreCommands.js'; import 'monaco-editor/esm/vs/editor/browser/widget/codeEditorWidget.js'; +import 'monaco-editor/esm/vs/editor/contrib/wordOperations/wordOperations.js'; // Needed for word-wise char navigation + import 'monaco-editor/esm/vs/editor/contrib/suggest/suggestController.js'; // Needed for suggestions import 'monaco-editor/esm/vs/editor/contrib/hover/hover.js'; // Needed for hover import 'monaco-editor/esm/vs/editor/contrib/parameterHints/parameterHints.js'; // Needed for signature diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index 9ebfeb5387b26..df61271baf879 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -132,4 +132,6 @@ export class DevToolsPlugin implements Plugin { getSortedDevTools: this.getSortedDevTools.bind(this), }; } + + public stop() {} } diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index a8bb989f6bff3..2a28e349ace99 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -30,6 +30,7 @@ "xpack.ml": ["plugins/ml", "legacy/plugins/ml"], "xpack.monitoring": ["plugins/monitoring", "legacy/plugins/monitoring"], "xpack.remoteClusters": "plugins/remote_clusters", + "xpack.painlessLab": "plugins/painless_lab", "xpack.reporting": ["plugins/reporting", "legacy/plugins/reporting"], "xpack.rollupJobs": "legacy/plugins/rollup", "xpack.searchProfiler": "plugins/searchprofiler", diff --git a/x-pack/plugins/painless_lab/common/constants.ts b/x-pack/plugins/painless_lab/common/constants.ts new file mode 100644 index 0000000000000..dfc7d8ae85a2c --- /dev/null +++ b/x-pack/plugins/painless_lab/common/constants.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { LicenseType } from '../../licensing/common/types'; + +const basicLicense: LicenseType = 'basic'; + +export const PLUGIN = { + id: 'painlessLab', + minimumLicenseType: basicLicense, +}; + +export const API_BASE_PATH = '/api/painless_lab'; diff --git a/x-pack/plugins/painless_lab/kibana.json b/x-pack/plugins/painless_lab/kibana.json new file mode 100644 index 0000000000000..4b4ea24202846 --- /dev/null +++ b/x-pack/plugins/painless_lab/kibana.json @@ -0,0 +1,16 @@ +{ + "id": "painlessLab", + "version": "8.0.0", + "kibanaVersion": "kibana", + "requiredPlugins": [ + "devTools", + "licensing", + "home" + ], + "configPath": [ + "xpack", + "painless_lab" + ], + "server": true, + "ui": true +} diff --git a/x-pack/plugins/painless_lab/public/application/components/editor.tsx b/x-pack/plugins/painless_lab/public/application/components/editor.tsx new file mode 100644 index 0000000000000..b8891ce6524f5 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/editor.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { CodeEditor } from '../../../../../../src/plugins/kibana_react/public'; + +interface Props { + code: string; + onChange: (code: string) => void; +} + +export function Editor({ code, onChange }: Props) { + return ( + + ); +} diff --git a/x-pack/plugins/painless_lab/public/application/components/main.tsx b/x-pack/plugins/painless_lab/public/application/components/main.tsx new file mode 100644 index 0000000000000..10907536e9cc2 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/main.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { formatRequestPayload, formatJson } from '../lib/format'; +import { exampleScript } from '../constants'; +import { PayloadFormat } from '../types'; +import { useSubmitCode } from '../hooks'; +import { useAppContext } from '../context'; +import { OutputPane } from './output_pane'; +import { MainControls } from './main_controls'; +import { Editor } from './editor'; +import { RequestFlyout } from './request_flyout'; + +export const Main: React.FunctionComponent = () => { + const { + store: { payload, validation }, + updatePayload, + services: { + http, + chrome: { getIsNavDrawerLocked$ }, + }, + links, + } = useAppContext(); + + const [isRequestFlyoutOpen, setRequestFlyoutOpen] = useState(false); + const { inProgress, response, submit } = useSubmitCode(http); + + // Live-update the output and persist payload state as the user changes it. + useEffect(() => { + if (validation.isValid) { + submit(payload); + } + }, [payload, submit, validation.isValid]); + + const toggleRequestFlyout = () => { + setRequestFlyoutOpen(!isRequestFlyoutOpen); + }; + + const [isNavDrawerLocked, setIsNavDrawerLocked] = useState(false); + + useEffect(() => { + const subscription = getIsNavDrawerLocked$().subscribe((newIsNavDrawerLocked: boolean) => { + setIsNavDrawerLocked(newIsNavDrawerLocked); + }); + + return () => subscription.unsubscribe(); + }); + + return ( +
+ + + +

+ {i18n.translate('xpack.painlessLab.title', { + defaultMessage: 'Painless Lab', + })} +

+
+ + updatePayload({ code: nextCode })} /> +
+ + + + +
+ + updatePayload({ code: exampleScript })} + /> + + {isRequestFlyoutOpen && ( + setRequestFlyoutOpen(false)} + requestBody={formatRequestPayload(payload, PayloadFormat.PRETTY)} + response={response ? formatJson(response.result || response.error) : ''} + /> + )} +
+ ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/components/main_controls.tsx b/x-pack/plugins/painless_lab/public/application/components/main_controls.tsx new file mode 100644 index 0000000000000..6307c21e26dc4 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/main_controls.tsx @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + EuiPopover, + EuiBottomBar, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { Links } from '../../links'; + +interface Props { + toggleRequestFlyout: () => void; + isRequestFlyoutOpen: boolean; + isLoading: boolean; + reset: () => void; + links: Links; + isNavDrawerLocked: boolean; +} + +export function MainControls({ + toggleRequestFlyout, + isRequestFlyoutOpen, + reset, + links, + isNavDrawerLocked, +}: Props) { + const [isHelpOpen, setIsHelpOpen] = useState(false); + + const items = [ + setIsHelpOpen(false)} + > + {i18n.translate('xpack.painlessLab.walkthroughButtonLabel', { + defaultMessage: 'Walkthrough', + })} + , + + setIsHelpOpen(false)} + > + {i18n.translate('xpack.painlessLab.apiReferenceButtonLabel', { + defaultMessage: 'API reference', + })} + , + + setIsHelpOpen(false)} + > + {i18n.translate('xpack.painlessLab.languageSpecButtonLabel', { + defaultMessage: 'Language spec', + })} + , + + { + reset(); + setIsHelpOpen(false); + }} + > + {i18n.translate('xpack.painlessLab.resetButtonLabel', { + defaultMessage: 'Reset script', + })} + , + ]; + + const classes = classNames('painlessLab__bottomBar', { + 'painlessLab__bottomBar-isNavDrawerLocked': isNavDrawerLocked, + }); + + return ( + + + + + + setIsHelpOpen(!isHelpOpen)} + > + {i18n.translate('xpack.painlessLab.helpButtonLabel', { + defaultMessage: 'Help', + })} + + } + isOpen={isHelpOpen} + closePopover={() => setIsHelpOpen(false)} + panelPaddingSize="none" + withTitle + anchorPosition="upRight" + > + + + + + + + + {isRequestFlyoutOpen + ? i18n.translate('xpack.painlessLab.hideRequestButtonLabel', { + defaultMessage: 'Hide API request', + }) + : i18n.translate('xpack.painlessLab.showRequestButtonLabel', { + defaultMessage: 'Show API request', + })} + + + + + ); +} diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx new file mode 100644 index 0000000000000..47efd524f092a --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx @@ -0,0 +1,202 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { + EuiFieldText, + EuiFormRow, + EuiPanel, + EuiSpacer, + EuiIcon, + EuiToolTip, + EuiLink, + EuiText, + EuiSuperSelect, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { CodeEditor } from '../../../../../../../src/plugins/kibana_react/public'; +import { painlessContextOptions } from '../../constants'; +import { useAppContext } from '../../context'; + +export const ContextTab: FunctionComponent = () => { + const { + store: { payload, validation }, + updatePayload, + links, + } = useAppContext(); + const { context, document, index, query } = payload; + + return ( + <> + + + + {' '} + + + + } + labelAppend={ + + + {i18n.translate('xpack.painlessLab.contextFieldDocLinkText', { + defaultMessage: 'Context docs', + })} + + + } + fullWidth + > + updatePayload({ context: nextContext })} + itemLayoutAlign="top" + hasDividers + fullWidth + /> + + + {['filter', 'score'].indexOf(context) !== -1 && ( + + + {' '} + + + + } + fullWidth + isInvalid={!validation.fields.index} + error={ + validation.fields.index + ? [] + : [ + i18n.translate('xpack.painlessLab.indexFieldMissingErrorMessage', { + defaultMessage: 'Enter an index name', + }), + ] + } + > + { + const nextIndex = e.target.value; + updatePayload({ index: nextIndex }); + }} + isInvalid={!validation.fields.index} + /> + + )} + {/* Query DSL Code Editor */} + {'score'.indexOf(context) !== -1 && ( + + + {' '} + + + + } + labelAppend={ + + + {i18n.translate('xpack.painlessLab.queryFieldDocLinkText', { + defaultMessage: 'Query DSL docs', + })} + + + } + fullWidth + > + + updatePayload({ query: nextQuery })} + options={{ + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + /> + + + )} + {['filter', 'score'].indexOf(context) !== -1 && ( + + + {' '} + + + + } + fullWidth + > + + updatePayload({ document: nextDocument })} + options={{ + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + /> + + + )} + + ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/index.ts b/x-pack/plugins/painless_lab/public/application/components/output_pane/index.ts new file mode 100644 index 0000000000000..85b7a7816b5aa --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/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 { OutputPane } from './output_pane'; diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx new file mode 100644 index 0000000000000..e6a97bb02f738 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx @@ -0,0 +1,79 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPanel, + EuiTabbedContent, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { Response } from '../../types'; +import { OutputTab } from './output_tab'; +import { ParametersTab } from './parameters_tab'; +import { ContextTab } from './context_tab'; + +interface Props { + isLoading: boolean; + response?: Response; +} + +export const OutputPane: FunctionComponent = ({ isLoading, response }) => { + const outputTabLabel = ( + + + {isLoading ? ( + + ) : response && response.error ? ( + + ) : ( + + )} + + + + {i18n.translate('xpack.painlessLab.outputTabLabel', { + defaultMessage: 'Output', + })} + + + ); + + return ( + + , + }, + { + id: 'parameters', + name: i18n.translate('xpack.painlessLab.parametersTabLabel', { + defaultMessage: 'Parameters', + }), + content: , + }, + { + id: 'context', + name: i18n.translate('xpack.painlessLab.contextTabLabel', { + defaultMessage: 'Context', + }), + content: , + }, + ]} + /> + + ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/output_tab.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_tab.tsx new file mode 100644 index 0000000000000..8969e5421640a --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_tab.tsx @@ -0,0 +1,26 @@ +/* + * 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 from 'react'; +import { EuiCodeBlock, EuiSpacer } from '@elastic/eui'; + +import { formatResponse } from '../../lib/format'; +import { Response } from '../../types'; + +interface Props { + response?: Response; +} + +export function OutputTab({ response }: Props) { + return ( + <> + + + {formatResponse(response)} + + + ); +} diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/parameters_tab.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/parameters_tab.tsx new file mode 100644 index 0000000000000..7c8bce0f7b21b --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/parameters_tab.tsx @@ -0,0 +1,87 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { + EuiFormRow, + EuiPanel, + EuiSpacer, + EuiIcon, + EuiToolTip, + EuiLink, + EuiText, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { monaco } from '@kbn/ui-shared-deps/monaco'; +import { i18n } from '@kbn/i18n'; +import { CodeEditor } from '../../../../../../../src/plugins/kibana_react/public'; + +import { useAppContext } from '../../context'; + +export const ParametersTab: FunctionComponent = () => { + const { + store: { payload }, + updatePayload, + links, + } = useAppContext(); + return ( + <> + + + + {' '} + + + + } + fullWidth + labelAppend={ + + + {i18n.translate('xpack.painlessLab.parametersFieldDocLinkText', { + defaultMessage: 'Parameters docs', + })} + + + } + > + + updatePayload({ parameters: nextParams })} + options={{ + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + editorDidMount={(editor: monaco.editor.IStandaloneCodeEditor) => { + // Updating tab size for the editor + const model = editor.getModel(); + if (model) { + model.updateOptions({ tabSize: 2 }); + } + }} + /> + + + + ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/components/request_flyout.tsx b/x-pack/plugins/painless_lab/public/application/components/request_flyout.tsx new file mode 100644 index 0000000000000..123df91f4346a --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/request_flyout.tsx @@ -0,0 +1,98 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { + EuiCodeBlock, + EuiTabbedContent, + EuiTitle, + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Links } from '../../links'; + +interface Props { + onClose: any; + requestBody: string; + links: Links; + response?: string; +} + +export const RequestFlyout: FunctionComponent = ({ + onClose, + requestBody, + response, + links, +}) => { + return ( + + + + + {/* We need an extra div to get out of flex grow */} +
+ +

+ {i18n.translate('xpack.painlessLab.flyoutTitle', { + defaultMessage: 'API request', + })} +

+
+
+
+ + + + {i18n.translate('xpack.painlessLab.flyoutDocLink', { + defaultMessage: 'API documentation', + })} + + +
+
+ + + + {'POST _scripts/painless/_execute\n'} + {requestBody} + + ), + }, + { + id: 'response', + name: 'Response', + content: ( + + {response} + + ), + }, + ]} + /> + +
+ + + ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/constants.tsx b/x-pack/plugins/painless_lab/public/application/constants.tsx new file mode 100644 index 0000000000000..d8430dbfc7d9d --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/constants.tsx @@ -0,0 +1,135 @@ +/* + * 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 from 'react'; + +import { EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const defaultLabel = i18n.translate('xpack.painlessLab.contextDefaultLabel', { + defaultMessage: 'Basic', +}); + +const filterLabel = i18n.translate('xpack.painlessLab.contextFilterLabel', { + defaultMessage: 'Filter', +}); + +const scoreLabel = i18n.translate('xpack.painlessLab.contextScoreLabel', { + defaultMessage: 'Score', +}); + +export const painlessContextOptions = [ + { + value: 'painless_test', + inputDisplay: defaultLabel, + dropdownDisplay: ( + <> + {defaultLabel} + +

+ {i18n.translate('xpack.painlessLab.context.defaultLabel', { + defaultMessage: 'The script result will be converted to a string', + })} +

+
+ + ), + }, + { + value: 'filter', + inputDisplay: filterLabel, + dropdownDisplay: ( + <> + {filterLabel} + +

+ {i18n.translate('xpack.painlessLab.context.filterLabel', { + defaultMessage: "Use the context of a filter's script query", + })} +

+
+ + ), + }, + { + value: 'score', + inputDisplay: scoreLabel, + dropdownDisplay: ( + <> + {scoreLabel} + +

+ {i18n.translate('xpack.painlessLab.context.scoreLabel', { + defaultMessage: 'Use the context of a script_score function in function_score query', + })} +

+
+ + ), + }, +]; + +// Render a smiley face as an example. +export const exampleScript = `boolean isInCircle(def posX, def posY, def circleX, def circleY, def radius) { + double distanceFromCircleCenter = Math.sqrt(Math.pow(circleX - posX, 2) + Math.pow(circleY - posY, 2)); + return distanceFromCircleCenter <= radius; +} + +boolean isOnCircle(def posX, def posY, def circleX, def circleY, def radius, def thickness, def squashY) { + double distanceFromCircleCenter = Math.sqrt(Math.pow(circleX - posX, 2) + Math.pow((circleY - posY) / squashY, 2)); + return ( + distanceFromCircleCenter >= radius - thickness + && distanceFromCircleCenter <= radius + thickness + ); +} + +def result = ''; +int charCount = 0; + +// Canvas dimensions +int width = 31; +int height = 31; +double halfWidth = Math.floor(width * 0.5); +double halfHeight = Math.floor(height * 0.5); + +// Style constants +double strokeWidth = 0.6; + +// Smiley face configuration +int headSize = 13; +double headSquashY = 0.78; +int eyePositionX = 10; +int eyePositionY = 12; +int eyeSize = 1; +int mouthSize = 15; +int mouthPositionX = width / 2; +int mouthPositionY = 5; +int mouthOffsetY = 11; + +for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + boolean isHead = isOnCircle(x, y, halfWidth, halfHeight, headSize, strokeWidth, headSquashY); + boolean isLeftEye = isInCircle(x, y, eyePositionX, eyePositionY, eyeSize); + boolean isRightEye = isInCircle(x, y, width - eyePositionX - 1, eyePositionY, eyeSize); + boolean isMouth = isOnCircle(x, y, mouthPositionX, mouthPositionY, mouthSize, strokeWidth, 1) && y > mouthPositionY + mouthOffsetY; + + if (isLeftEye || isRightEye || isMouth || isHead) { + result += "*"; + } else { + result += "."; + } + + result += " "; + + // Make sure the smiley face doesn't deform as the container changes width. + charCount++; + if (charCount % width === 0) { + result += "\\\\n"; + } + } +} + +return result;`; diff --git a/x-pack/plugins/painless_lab/public/application/context/context.tsx b/x-pack/plugins/painless_lab/public/application/context/context.tsx new file mode 100644 index 0000000000000..0fb5842dfea58 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/context/context.tsx @@ -0,0 +1,95 @@ +/* + * 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, { createContext, ReactNode, useState, useContext } from 'react'; +import { HttpSetup, ChromeStart } from 'src/core/public'; + +import { Links } from '../../links'; +import { Store, Payload, Validation } from '../types'; +import { initialPayload } from './initial_payload'; + +interface AppContextProviderArgs { + children: ReactNode; + value: { + http: HttpSetup; + links: Links; + chrome: ChromeStart; + }; +} + +interface ContextValue { + store: Store; + updatePayload: (changes: Partial) => void; + services: { + http: HttpSetup; + chrome: ChromeStart; + }; + links: Links; +} + +const AppContext = createContext(undefined as any); + +const validatePayload = (payload: Payload): Validation => { + const { index } = payload; + + // For now just validate that the user has entered an index. + const indexExists = Boolean(index || index.trim()); + + return { + isValid: indexExists, + fields: { + index: indexExists, + }, + }; +}; + +export const AppContextProvider = ({ + children, + value: { http, links, chrome }, +}: AppContextProviderArgs) => { + const PAINLESS_LAB_KEY = 'painlessLabState'; + + const [store, setStore] = useState(() => { + // Using a callback here ensures these values are only calculated on the first render. + const defaultPayload = { + ...initialPayload, + ...JSON.parse(localStorage.getItem(PAINLESS_LAB_KEY) || '{}'), + }; + + return { + payload: defaultPayload, + validation: validatePayload(defaultPayload), + }; + }); + + const updatePayload = (changes: Partial): void => { + const nextPayload = { + ...store.payload, + ...changes, + }; + // Persist state locally so we can load it up when the user reopens the app. + localStorage.setItem(PAINLESS_LAB_KEY, JSON.stringify(nextPayload)); + + setStore({ + payload: nextPayload, + validation: validatePayload(nextPayload), + }); + }; + + return ( + + {children} + + ); +}; + +export const useAppContext = () => { + const ctx = useContext(AppContext); + if (!ctx) { + throw new Error('AppContext can only be used inside of AppContextProvider!'); + } + return ctx; +}; diff --git a/x-pack/plugins/painless_lab/public/application/context/index.tsx b/x-pack/plugins/painless_lab/public/application/context/index.tsx new file mode 100644 index 0000000000000..7a685137b7a4f --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/context/index.tsx @@ -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 { AppContextProvider, useAppContext } from './context'; diff --git a/x-pack/plugins/painless_lab/public/application/context/initial_payload.ts b/x-pack/plugins/painless_lab/public/application/context/initial_payload.ts new file mode 100644 index 0000000000000..4d9d8ad8b3ae7 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/context/initial_payload.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { exampleScript, painlessContextOptions } from '../constants'; + +export const initialPayload = { + context: painlessContextOptions[0].value, + code: exampleScript, + parameters: `{ + "string_parameter": "string value", + "number_parameter": 1.5, + "boolean_parameter": true +}`, + index: 'my-index', + document: `{ + "my_field": "field_value" +}`, + query: '', +}; diff --git a/x-pack/plugins/painless_lab/public/application/hooks/index.ts b/x-pack/plugins/painless_lab/public/application/hooks/index.ts new file mode 100644 index 0000000000000..159ff96d2278c --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/hooks/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 { useSubmitCode } from './use_submit_code'; diff --git a/x-pack/plugins/painless_lab/public/application/hooks/use_submit_code.ts b/x-pack/plugins/painless_lab/public/application/hooks/use_submit_code.ts new file mode 100644 index 0000000000000..36cd4f280ac4c --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/hooks/use_submit_code.ts @@ -0,0 +1,64 @@ +/* + * 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 { useRef, useCallback, useState } from 'react'; +import { HttpSetup } from 'kibana/public'; +import { debounce } from 'lodash'; + +import { API_BASE_PATH } from '../../../common/constants'; +import { Response, PayloadFormat, Payload } from '../types'; +import { formatRequestPayload } from '../lib/format'; + +const DEBOUNCE_MS = 800; + +export const useSubmitCode = (http: HttpSetup) => { + const currentRequestIdRef = useRef(0); + const [response, setResponse] = useState(undefined); + const [inProgress, setInProgress] = useState(false); + + const submit = useCallback( + debounce( + async (config: Payload) => { + setInProgress(true); + + // Prevent an older request that resolves after a more recent request from clobbering it. + // We store the resulting ID in this closure for comparison when the request resolves. + const requestId = ++currentRequestIdRef.current; + + try { + const result = await http.post(`${API_BASE_PATH}/execute`, { + // Stringify the string, because http runs it through JSON.parse, and we want to actually + // send a JSON string. + body: JSON.stringify(formatRequestPayload(config, PayloadFormat.UGLY)), + }); + + if (currentRequestIdRef.current === requestId) { + setResponse(result); + setInProgress(false); + } + // else ignore this response... + } catch (error) { + if (currentRequestIdRef.current === requestId) { + setResponse({ + error, + }); + setInProgress(false); + } + // else ignore this response... + } + }, + DEBOUNCE_MS, + { trailing: true } + ), + [http] + ); + + return { + response, + inProgress, + submit, + }; +}; diff --git a/x-pack/plugins/painless_lab/public/application/index.tsx b/x-pack/plugins/painless_lab/public/application/index.tsx new file mode 100644 index 0000000000000..ebcb84bbce83c --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/index.tsx @@ -0,0 +1,46 @@ +/* + * 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 from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { CoreSetup, CoreStart } from 'kibana/public'; +import { HttpSetup, ChromeStart } from 'src/core/public'; +import { createKibanaReactContext } from '../../../../../src/plugins/kibana_react/public'; + +import { Links } from '../links'; +import { AppContextProvider } from './context'; +import { Main } from './components/main'; + +interface AppDependencies { + http: HttpSetup; + I18nContext: CoreStart['i18n']['Context']; + uiSettings: CoreSetup['uiSettings']; + links: Links; + chrome: ChromeStart; +} + +export function renderApp( + element: HTMLElement | null, + { http, I18nContext, uiSettings, links, chrome }: AppDependencies +) { + if (!element) { + return () => undefined; + } + const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ + uiSettings, + }); + render( + + + +
+ + + , + element + ); + return () => unmountComponentAtNode(element); +} diff --git a/x-pack/plugins/painless_lab/public/application/lib/__snapshots__/format.test.ts.snap b/x-pack/plugins/painless_lab/public/application/lib/__snapshots__/format.test.ts.snap new file mode 100644 index 0000000000000..4df90d1b3abe1 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/lib/__snapshots__/format.test.ts.snap @@ -0,0 +1,205 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`formatRequestPayload pretty formats a complex multi-line script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"// Here's a comment and a variable, then a loop. + double halfWidth = Math.floor(width * 0.5); + for (int y = 0; y < height; y++) { + return \\"results here\\\\\\\\n\\"; + } + + return result;\\"\\"\\" + } +}" +`; + +exports[`formatRequestPayload pretty formats a single-line script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\" + } +}" +`; + +exports[`formatRequestPayload pretty formats code and parameters 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + } +}" +`; + +exports[`formatRequestPayload pretty formats code, context, index, and document 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\" + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"index\\", + \\"document\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + } +}" +`; + +exports[`formatRequestPayload pretty formats code, parameters, and context 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"\\", + \\"document\\": + } +}" +`; + +exports[`formatRequestPayload pretty formats code, parameters, context, index, and document 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"index\\", + \\"document\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + } +}" +`; + +exports[`formatRequestPayload pretty formats no script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"\\"\\"\\" + } +}" +`; + +exports[`formatRequestPayload ugly formats a complex multi-line script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"// Here's a comment and a variable, then a loop.\\\\ndouble halfWidth = Math.floor(width * 0.5);\\\\nfor (int y = 0; y < height; y++) {\\\\n return \\\\\\"results here\\\\\\\\\\\\\\\\n\\\\\\";\\\\n}\\\\n\\\\nreturn result;\\" + } +}" +`; + +exports[`formatRequestPayload ugly formats a single-line script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\" + } +}" +`; + +exports[`formatRequestPayload ugly formats code and parameters 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + } +}" +`; + +exports[`formatRequestPayload ugly formats code, context, index, and document 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\" + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"index\\", + \\"document\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + } +}" +`; + +exports[`formatRequestPayload ugly formats code, parameters, and context 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"undefined\\", + \\"document\\": undefined + } +}" +`; + +exports[`formatRequestPayload ugly formats code, parameters, context, index, and document 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"index\\", + \\"document\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + } +}" +`; + +exports[`formatRequestPayload ugly formats no script 1`] = ` +"{ + \\"script\\": { + \\"source\\": undefined + } +}" +`; diff --git a/x-pack/plugins/painless_lab/public/application/lib/format.test.ts b/x-pack/plugins/painless_lab/public/application/lib/format.test.ts new file mode 100644 index 0000000000000..5f0022ebbc089 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/lib/format.test.ts @@ -0,0 +1,86 @@ +/* + * 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 { PayloadFormat } from '../types'; +import { formatRequestPayload } from './format'; + +describe('formatRequestPayload', () => { + Object.values(PayloadFormat).forEach(format => { + describe(`${format} formats`, () => { + test('no script', () => { + expect(formatRequestPayload({}, format)).toMatchSnapshot(); + }); + + test('a single-line script', () => { + const code = 'return "ok";'; + expect(formatRequestPayload({ code }, format)).toMatchSnapshot(); + }); + + test('a complex multi-line script', () => { + const code = `// Here's a comment and a variable, then a loop. +double halfWidth = Math.floor(width * 0.5); +for (int y = 0; y < height; y++) { + return "results here\\\\n"; +} + +return result;`; + expect(formatRequestPayload({ code }, format)).toMatchSnapshot(); + }); + + test('code and parameters', () => { + const code = 'return "ok";'; + const parameters = `{ + "a": { + "b": "c", + "d": "e" + } +}`; + expect(formatRequestPayload({ code, parameters }, format)).toMatchSnapshot(); + }); + + test('code, parameters, and context', () => { + const code = 'return "ok";'; + const parameters = `{ + "a": { + "b": "c", + "d": "e" + } +}`; + const context = 'filter'; + expect(formatRequestPayload({ code, parameters, context }, format)).toMatchSnapshot(); + }); + + test('code, context, index, and document', () => { + const code = 'return "ok";'; + const context = 'filter'; + const index = 'index'; + const document = `{ + "a": { + "b": "c", + "d": "e" + } +}`; + expect(formatRequestPayload({ code, context, index, document }, format)).toMatchSnapshot(); + }); + + test('code, parameters, context, index, and document', () => { + const code = 'return "ok";'; + const parameters = `{ + "a": { + "b": "c", + "d": "e" + } +}`; + const context = 'filter'; + const index = 'index'; + const document = parameters; + expect( + formatRequestPayload({ code, parameters, context, index, document }, format) + ).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/x-pack/plugins/painless_lab/public/application/lib/format.ts b/x-pack/plugins/painless_lab/public/application/lib/format.ts new file mode 100644 index 0000000000000..15ecdf682d247 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/lib/format.ts @@ -0,0 +1,117 @@ +/* + * 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 { Response, ExecutionError, PayloadFormat, Payload } from '../types'; + +function prettifyPayload(payload = '', indentationLevel = 0) { + const indentation = new Array(indentationLevel + 1).join(' '); + return payload.replace(/\n/g, `\n${indentation}`); +} + +/** + * Values should be preserved as strings so that floating point precision, + * e.g. 1.0, is preserved instead of being coerced to an integer, e.g. 1. + */ +export function formatRequestPayload( + { code, context, parameters, index, document, query }: Partial, + format: PayloadFormat = PayloadFormat.UGLY +): string { + const isAdvancedContext = context === 'filter' || context === 'score'; + + let formattedCode: string | undefined; + let formattedParameters: string | undefined; + let formattedContext: string | undefined; + let formattedIndex: string | undefined; + let formattedDocument: string | undefined; + let formattedQuery: string | undefined; + + if (format === PayloadFormat.UGLY) { + formattedCode = JSON.stringify(code); + formattedParameters = parameters; + formattedContext = context; + formattedIndex = index; + formattedDocument = document; + formattedQuery = query; + } else { + // Triple quote the code because it's multiline. + formattedCode = `"""${prettifyPayload(code, 4)}"""`; + formattedParameters = prettifyPayload(parameters, 4); + formattedContext = prettifyPayload(context, 6); + formattedIndex = prettifyPayload(index); + formattedDocument = prettifyPayload(document, 4); + formattedQuery = prettifyPayload(query, 4); + } + + const requestPayload = `{ + "script": { + "source": ${formattedCode}${ + parameters + ? `, + "params": ${formattedParameters}` + : `` + } + }${ + isAdvancedContext + ? `, + "context": "${formattedContext}", + "context_setup": { + "index": "${formattedIndex}", + "document": ${formattedDocument}${ + query && context === 'score' + ? `, + "query": ${formattedQuery}` + : '' + } + }` + : `` + } +}`; + return requestPayload; +} + +/** + * Stringify a given object to JSON in a formatted way + */ +export function formatJson(json: unknown): string { + try { + return JSON.stringify(json, null, 2); + } catch (e) { + return `Invalid JSON ${String(json)}`; + } +} + +export function formatResponse(response?: Response): string { + if (!response) { + return ''; + } + if (typeof response.result === 'string') { + return response.result.replace(/\\n/g, '\n'); + } else if (response.error) { + return formatExecutionError(response.error); + } + return formatJson(response); +} + +export function formatExecutionError(executionErrorOrError: ExecutionError | Error): string { + if (executionErrorOrError instanceof Error) { + return executionErrorOrError.message; + } + + if ( + executionErrorOrError.script_stack && + executionErrorOrError.caused_by && + executionErrorOrError.position + ) { + return `Unhandled Exception ${executionErrorOrError.caused_by.type} + +${executionErrorOrError.caused_by.reason} + +Stack: +${formatJson(executionErrorOrError.script_stack)} +`; + } + return formatJson(executionErrorOrError); +} diff --git a/x-pack/plugins/painless_lab/public/application/types.ts b/x-pack/plugins/painless_lab/public/application/types.ts new file mode 100644 index 0000000000000..d800558ef7ecc --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/types.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface Store { + payload: Payload; + validation: Validation; +} + +export interface Payload { + context: string; + code: string; + parameters: string; + index: string; + document: string; + query: string; +} + +export interface Validation { + isValid: boolean; + fields: { + index: boolean; + }; +} + +// TODO: This should be an enumerated list +export type Context = string; + +export enum PayloadFormat { + UGLY = 'ugly', + PRETTY = 'pretty', +} + +export interface Response { + error?: ExecutionError | Error; + result?: string; +} + +export type ExecutionErrorScriptStack = string[]; + +export interface ExecutionErrorPosition { + start: number; + end: number; + offset: number; +} + +export interface ExecutionError { + script_stack?: ExecutionErrorScriptStack; + caused_by?: { + type: string; + reason: string; + }; + message?: string; + position: ExecutionErrorPosition; + script: string; +} diff --git a/x-pack/plugins/painless_lab/public/index.scss b/x-pack/plugins/painless_lab/public/index.scss new file mode 100644 index 0000000000000..29a5761255278 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/index.scss @@ -0,0 +1 @@ +@import 'styles/index'; diff --git a/x-pack/plugins/painless_lab/public/index.ts b/x-pack/plugins/painless_lab/public/index.ts new file mode 100644 index 0000000000000..da357e52af676 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './styles/_index.scss'; +import { PainlessLabUIPlugin } from './plugin'; + +export function plugin() { + return new PainlessLabUIPlugin(); +} diff --git a/x-pack/plugins/painless_lab/public/lib/index.ts b/x-pack/plugins/painless_lab/public/lib/index.ts new file mode 100644 index 0000000000000..2421307b7c107 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/lib/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 { monacoPainlessLang } from './monaco_painless_lang'; diff --git a/x-pack/plugins/painless_lab/public/lib/monaco_painless_lang.ts b/x-pack/plugins/painless_lab/public/lib/monaco_painless_lang.ts new file mode 100644 index 0000000000000..602697064a768 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/lib/monaco_painless_lang.ts @@ -0,0 +1,174 @@ +/* + * 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 * as monaco from 'monaco-editor'; + +/** + * Extends the default type for a Monarch language so we can use + * attribute references (like @keywords to reference the keywords list) + * in the defined tokenizer + */ +interface Language extends monaco.languages.IMonarchLanguage { + default: string; + brackets: any; + keywords: string[]; + symbols: RegExp; + escapes: RegExp; + digits: RegExp; + primitives: string[]; + octaldigits: RegExp; + binarydigits: RegExp; + constants: string[]; + operators: string[]; +} + +export const monacoPainlessLang = { + default: '', + // painless does not use < >, so we define our own + brackets: [ + ['{', '}', 'delimiter.curly'], + ['[', ']', 'delimiter.square'], + ['(', ')', 'delimiter.parenthesis'], + ], + keywords: [ + 'if', + 'in', + 'else', + 'while', + 'do', + 'for', + 'continue', + 'break', + 'return', + 'new', + 'try', + 'catch', + 'throw', + 'this', + 'instanceof', + ], + primitives: ['void', 'boolean', 'byte', 'short', 'char', 'int', 'long', 'float', 'double', 'def'], + constants: ['true', 'false'], + operators: [ + '=', + '>', + '<', + '!', + '~', + '?', + '?:', + '?.', + ':', + '==', + '===', + '<=', + '>=', + '!=', + '!==', + '&&', + '||', + '++', + '--', + '+', + '-', + '*', + '/', + '&', + '|', + '^', + '%', + '<<', + '>>', + '>>>', + '+=', + '-=', + '*=', + '/=', + '&=', + '|=', + '^=', + '%=', + '<<=', + '>>=', + '>>>=', + '->', + '::', + '=~', + '==~', + ], + symbols: /[=>; + +export const getLinks = ({ DOC_LINK_VERSION, ELASTIC_WEBSITE_URL }: DocLinksStart) => + Object.freeze({ + painlessExecuteAPI: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-execute-api.html`, + painlessExecuteAPIContexts: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-execute-api.html#_contexts`, + painlessAPIReference: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-api-reference.html`, + painlessWalkthrough: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-walkthrough.html`, + painlessLangSpec: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-lang-spec.html`, + esQueryDSL: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/query-dsl.html`, + modulesScriptingPreferParams: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/modules-scripting-using.html#prefer-params`, + }); diff --git a/x-pack/plugins/painless_lab/public/plugin.tsx b/x-pack/plugins/painless_lab/public/plugin.tsx new file mode 100644 index 0000000000000..b9ca7031cf670 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/plugin.tsx @@ -0,0 +1,114 @@ +/* + * 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 from 'react'; +import { i18n } from '@kbn/i18n'; +import { Plugin, CoreStart, CoreSetup } from 'kibana/public'; +import { first } from 'rxjs/operators'; +import { EuiBetaBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; +import { LICENSE_CHECK_STATE } from '../../licensing/public'; + +import { PLUGIN } from '../common/constants'; + +import { PluginDependencies } from './types'; +import { getLinks } from './links'; +import { LanguageService } from './services'; + +export class PainlessLabUIPlugin implements Plugin { + languageService = new LanguageService(); + + async setup( + { http, getStartServices, uiSettings }: CoreSetup, + { devTools, home, licensing }: PluginDependencies + ) { + home.featureCatalogue.register({ + id: PLUGIN.id, + title: i18n.translate('xpack.painlessLab.registryProviderTitle', { + defaultMessage: 'Painless Lab (beta)', + }), + description: i18n.translate('xpack.painlessLab.registryProviderDescription', { + defaultMessage: 'Simulate and debug painless code.', + }), + icon: '', + path: '/app/kibana#/dev_tools/painless_lab', + showOnHomePage: false, + category: FeatureCatalogueCategory.ADMIN, + }); + + devTools.register({ + id: 'painless_lab', + order: 7, + title: ( + + + {i18n.translate('xpack.painlessLab.displayName', { + defaultMessage: 'Painless Lab', + })} + + + + + + + ) as any, + enableRouting: false, + disabled: false, + mount: async (ctx, { element }) => { + const [core] = await getStartServices(); + + const { + i18n: { Context: I18nContext }, + notifications, + docLinks, + chrome, + } = core; + + this.languageService.setup(); + + const license = await licensing.license$.pipe(first()).toPromise(); + const { state, message: invalidLicenseMessage } = license.check( + PLUGIN.id, + PLUGIN.minimumLicenseType + ); + const isValidLicense = state === LICENSE_CHECK_STATE.Valid; + + if (!isValidLicense) { + notifications.toasts.addDanger(invalidLicenseMessage as string); + window.location.hash = '/dev_tools'; + return () => {}; + } + + const { renderApp } = await import('./application'); + const tearDownApp = renderApp(element, { + I18nContext, + http, + uiSettings, + links: getLinks(docLinks), + chrome, + }); + + return () => { + tearDownApp(); + }; + }, + }); + } + + async start(core: CoreStart, plugins: any) {} + + async stop() { + this.languageService.stop(); + } +} diff --git a/x-pack/plugins/painless_lab/public/services/index.ts b/x-pack/plugins/painless_lab/public/services/index.ts new file mode 100644 index 0000000000000..20bec9de24550 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/services/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { LanguageService } from './language_service'; diff --git a/x-pack/plugins/painless_lab/public/services/language_service.ts b/x-pack/plugins/painless_lab/public/services/language_service.ts new file mode 100644 index 0000000000000..efff9cd0e78d5 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/services/language_service.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// It is important that we use this specific monaco instance so that +// editor settings are registered against the instance our React component +// uses. +import { monaco } from '@kbn/ui-shared-deps/monaco'; + +// @ts-ignore +import workerSrc from 'raw-loader!monaco-editor/min/vs/base/worker/workerMain.js'; + +import { monacoPainlessLang } from '../lib'; + +const LANGUAGE_ID = 'painless'; + +// Safely check whether these globals are present +const CAN_CREATE_WORKER = typeof Blob === 'function' && typeof Worker === 'function'; + +export class LanguageService { + private originalMonacoEnvironment: any; + + public setup() { + monaco.languages.register({ id: LANGUAGE_ID }); + monaco.languages.setMonarchTokensProvider(LANGUAGE_ID, monacoPainlessLang); + + if (CAN_CREATE_WORKER) { + this.originalMonacoEnvironment = (window as any).MonacoEnvironment; + (window as any).MonacoEnvironment = { + getWorker: () => { + const blob = new Blob([workerSrc], { type: 'application/javascript' }); + return new Worker(window.URL.createObjectURL(blob)); + }, + }; + } + } + + public stop() { + if (CAN_CREATE_WORKER) { + (window as any).MonacoEnvironment = this.originalMonacoEnvironment; + } + } +} diff --git a/x-pack/plugins/painless_lab/public/styles/_index.scss b/x-pack/plugins/painless_lab/public/styles/_index.scss new file mode 100644 index 0000000000000..f68dbe302511a --- /dev/null +++ b/x-pack/plugins/painless_lab/public/styles/_index.scss @@ -0,0 +1,58 @@ +@import '@elastic/eui/src/components/header/variables'; +@import '@elastic/eui/src/components/nav_drawer/variables'; + +/** + * This is a very brittle way of preventing the editor and other content from disappearing + * behind the bottom bar. + */ +$bottomBarHeight: calc(#{$euiSize} * 3); + +.painlessLabBottomBarPlaceholder { + height: $bottomBarHeight +} + +.painlessLabRightPane { + border-right: none; + border-top: none; + border-bottom: none; + border-radius: 0; + padding-top: 0; + height: 100%; +} + +.painlessLabRightPane__tabs { + display: flex; + flex-direction: column; + height: 100%; + + [role="tabpanel"] { + height: 100%; + overflow-y: auto; + } +} + +.painlessLab__betaLabelContainer { + line-height: 0; +} + +.painlessLabMainContainer { + height: calc(100vh - calc(#{$euiHeaderChildSize} * 2) - #{$bottomBarHeight}); +} + +.painlessLabPanelsContainer { + // The panels container should adopt the height of the main container + height: 100%; +} + +/** + * 1. Hack EUI so the bottom bar doesn't obscure the nav drawer flyout, but is also not obscured + * by the main content area. + */ +.painlessLab__bottomBar { + z-index: 5; /* 1 */ + left: $euiNavDrawerWidthCollapsed; +} + +.painlessLab__bottomBar-isNavDrawerLocked { + left: $euiNavDrawerWidthExpanded; +} diff --git a/x-pack/plugins/painless_lab/public/types.ts b/x-pack/plugins/painless_lab/public/types.ts new file mode 100644 index 0000000000000..9153f4c28de8d --- /dev/null +++ b/x-pack/plugins/painless_lab/public/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import { DevToolsSetup } from '../../../../src/plugins/dev_tools/public'; +import { LicensingPluginSetup } from '../../licensing/public'; + +export interface PluginDependencies { + licensing: LicensingPluginSetup; + home: HomePublicPluginSetup; + devTools: DevToolsSetup; +} diff --git a/x-pack/plugins/painless_lab/server/index.ts b/x-pack/plugins/painless_lab/server/index.ts new file mode 100644 index 0000000000000..96ea9a163deca --- /dev/null +++ b/x-pack/plugins/painless_lab/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { PluginInitializerContext } from 'kibana/server'; +import { PainlessLabServerPlugin } from './plugin'; + +export const plugin = (ctx: PluginInitializerContext) => { + return new PainlessLabServerPlugin(ctx); +}; diff --git a/x-pack/plugins/painless_lab/server/lib/index.ts b/x-pack/plugins/painless_lab/server/lib/index.ts new file mode 100644 index 0000000000000..a9a3c61472d8c --- /dev/null +++ b/x-pack/plugins/painless_lab/server/lib/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 { isEsError } from './is_es_error'; diff --git a/x-pack/plugins/painless_lab/server/lib/is_es_error.ts b/x-pack/plugins/painless_lab/server/lib/is_es_error.ts new file mode 100644 index 0000000000000..4137293cf39c0 --- /dev/null +++ b/x-pack/plugins/painless_lab/server/lib/is_es_error.ts @@ -0,0 +1,13 @@ +/* + * 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 * as legacyElasticsearch from 'elasticsearch'; + +const esErrorsParent = legacyElasticsearch.errors._Abstract; + +export function isEsError(err: Error) { + return err instanceof esErrorsParent; +} diff --git a/x-pack/plugins/painless_lab/server/plugin.ts b/x-pack/plugins/painless_lab/server/plugin.ts new file mode 100644 index 0000000000000..74629a0b035ed --- /dev/null +++ b/x-pack/plugins/painless_lab/server/plugin.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; + +import { PLUGIN } from '../common/constants'; +import { License } from './services'; +import { Dependencies } from './types'; +import { registerExecuteRoute } from './routes/api'; + +export class PainlessLabServerPlugin implements Plugin { + private readonly license: License; + private readonly logger: Logger; + + constructor({ logger }: PluginInitializerContext) { + this.logger = logger.get(); + this.license = new License(); + } + + async setup({ http }: CoreSetup, { licensing }: Dependencies) { + const router = http.createRouter(); + + this.license.setup( + { + pluginId: PLUGIN.id, + minimumLicenseType: PLUGIN.minimumLicenseType, + defaultErrorMessage: i18n.translate('xpack.painlessLab.licenseCheckErrorMessage', { + defaultMessage: 'License check failed', + }), + }, + { + licensing, + logger: this.logger, + } + ); + + registerExecuteRoute({ router, license: this.license }); + } + + start() {} + + stop() {} +} diff --git a/x-pack/plugins/painless_lab/server/routes/api/execute.ts b/x-pack/plugins/painless_lab/server/routes/api/execute.ts new file mode 100644 index 0000000000000..55adb5e0410cc --- /dev/null +++ b/x-pack/plugins/painless_lab/server/routes/api/execute.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; + +import { API_BASE_PATH } from '../../../common/constants'; +import { RouteDependencies } from '../../types'; +import { isEsError } from '../../lib'; + +const bodySchema = schema.string(); + +export function registerExecuteRoute({ router, license }: RouteDependencies) { + router.post( + { + path: `${API_BASE_PATH}/execute`, + validate: { + body: bodySchema, + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const body = req.body; + + try { + const callAsCurrentUser = ctx.core.elasticsearch.dataClient.callAsCurrentUser; + const response = await callAsCurrentUser('scriptsPainlessExecute', { + body, + }); + + return res.ok({ + body: response, + }); + } catch (e) { + if (isEsError(e)) { + // Assume invalid painless script was submitted + // Return 200 with error object + return res.ok({ + body: e.body, + }); + } + return res.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/painless_lab/server/routes/api/index.ts b/x-pack/plugins/painless_lab/server/routes/api/index.ts new file mode 100644 index 0000000000000..62f05971d59cc --- /dev/null +++ b/x-pack/plugins/painless_lab/server/routes/api/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 { registerExecuteRoute } from './execute'; diff --git a/x-pack/plugins/painless_lab/server/services/index.ts b/x-pack/plugins/painless_lab/server/services/index.ts new file mode 100644 index 0000000000000..b7a45e59549eb --- /dev/null +++ b/x-pack/plugins/painless_lab/server/services/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { License } from './license'; diff --git a/x-pack/plugins/painless_lab/server/services/license.ts b/x-pack/plugins/painless_lab/server/services/license.ts new file mode 100644 index 0000000000000..1c9d77198f928 --- /dev/null +++ b/x-pack/plugins/painless_lab/server/services/license.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Logger } from 'src/core/server'; +import { + KibanaRequest, + KibanaResponseFactory, + RequestHandler, + RequestHandlerContext, +} from 'kibana/server'; + +import { LicensingPluginSetup } from '../../../licensing/server'; +import { LicenseType, LICENSE_CHECK_STATE } from '../../../licensing/common/types'; + +export interface LicenseStatus { + isValid: boolean; + message?: string; +} + +interface SetupSettings { + pluginId: string; + minimumLicenseType: LicenseType; + defaultErrorMessage: string; +} + +export class License { + private licenseStatus: LicenseStatus = { + isValid: false, + message: 'Invalid License', + }; + + setup( + { pluginId, minimumLicenseType, defaultErrorMessage }: SetupSettings, + { licensing, logger }: { licensing: LicensingPluginSetup; logger: Logger } + ) { + licensing.license$.subscribe(license => { + const { state, message } = license.check(pluginId, minimumLicenseType); + const hasRequiredLicense = state === LICENSE_CHECK_STATE.Valid; + + if (hasRequiredLicense) { + this.licenseStatus = { isValid: true }; + } else { + this.licenseStatus = { + isValid: false, + message: message || defaultErrorMessage, + }; + if (message) { + logger.info(message); + } + } + }); + } + + guardApiRoute(handler: RequestHandler) { + const license = this; + + return function licenseCheck( + ctx: RequestHandlerContext, + request: KibanaRequest, + response: KibanaResponseFactory + ) { + const licenseStatus = license.getStatus(); + + if (!licenseStatus.isValid) { + return response.customError({ + body: { + message: licenseStatus.message || '', + }, + statusCode: 403, + }); + } + + return handler(ctx, request, response); + }; + } + + getStatus() { + return this.licenseStatus; + } +} diff --git a/x-pack/plugins/painless_lab/server/types.ts b/x-pack/plugins/painless_lab/server/types.ts new file mode 100644 index 0000000000000..541a31dd175ec --- /dev/null +++ b/x-pack/plugins/painless_lab/server/types.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { IRouter } from 'src/core/server'; +import { LicensingPluginSetup } from '../../licensing/server'; +import { License } from './services'; + +export interface RouteDependencies { + router: IRouter; + license: License; +} + +export interface Dependencies { + licensing: LicensingPluginSetup; +} From 81b372363367e8fe6085ecaf25b009d84674a1b2 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Mon, 23 Mar 2020 19:26:49 -0400 Subject: [PATCH 052/179] [SIEM] [CASES] Build lego blocks case details view (#60864) * modify API to get the total comments in _find + Add user action to track what user are doing + create _pushed api to know when case have been pushed * fix rebase * add connector name in case configuration saved object * fix total comment in all cases * totalComment bug on the API * integrate user action API with UI * fix merged issue * integration APi to push to services with UI * Fix bugs * wip to show pushed service in ui * finish the full flow with pushing to service now * review about client discrepency * clean up + review * merge issue * update error msgs to info * add aria label + fix but on add/remove tags * fix i18n Co-authored-by: Christos Nasikas --- .../components/link_to/redirect_to_case.tsx | 7 +- .../siem/public/components/links/index.tsx | 28 ++- .../components/url_state/index.test.tsx | 2 +- .../siem/public/components/url_state/types.ts | 16 +- .../siem/public/containers/case/api.ts | 64 ++++++ .../public/containers/case/configure/types.ts | 1 + .../case/configure/use_configure.tsx | 27 ++- .../public/containers/case/translations.ts | 7 + .../siem/public/containers/case/types.ts | 34 ++- .../case/use_get_action_license.tsx | 74 ++++++ .../public/containers/case/use_get_case.tsx | 3 +- .../case/use_get_case_user_actions.tsx | 126 ++++++++++ .../case/use_post_push_to_service.tsx | 183 +++++++++++++++ .../containers/case/use_update_case.tsx | 13 +- .../containers/case/use_update_comment.tsx | 12 +- .../siem/public/containers/case/utils.ts | 16 ++ .../case/components/add_comment/index.tsx | 129 ++++++----- .../components/all_cases/__mock__/index.tsx | 19 +- .../case/components/all_cases/columns.tsx | 7 +- .../case/components/all_cases/index.test.tsx | 4 +- .../pages/case/components/all_cases/index.tsx | 20 +- .../case/components/case_status/index.tsx | 85 ++++--- .../components/case_view/__mock__/index.tsx | 24 +- .../case/components/case_view/index.test.tsx | 42 +++- .../pages/case/components/case_view/index.tsx | 118 ++++++++-- .../components/case_view/push_to_service.tsx | 185 +++++++++++++++ .../case/components/case_view/translations.ts | 96 +++++++- .../case/components/configure_cases/index.tsx | 10 +- .../errors_push_service_callout/index.tsx | 33 +++ .../translations.ts | 21 ++ .../components/user_action_tree/helpers.tsx | 75 ++++++ .../components/user_action_tree/index.tsx | 215 ++++++++++++++---- .../user_action_tree/translations.ts | 34 +++ .../user_action_tree/user_action_item.tsx | 134 ++++++++--- .../user_action_tree/user_action_title.tsx | 134 ++++++++--- .../plugins/siem/public/pages/case/index.tsx | 4 + .../siem/public/pages/case/translations.ts | 38 +++- .../plugins/siem/public/pages/case/utils.ts | 4 +- x-pack/plugins/case/common/api/cases/case.ts | 85 ++++++- .../plugins/case/common/api/cases/comment.ts | 2 + .../case/common/api/cases/configure.ts | 1 + x-pack/plugins/case/common/api/cases/index.ts | 1 + .../case/common/api/cases/user_actions.ts | 59 +++++ x-pack/plugins/case/server/plugin.ts | 7 +- .../routes/api/__fixtures__/mock_router.ts | 4 + .../api/__fixtures__/mock_saved_objects.ts | 16 +- .../api/cases/comments/delete_all_comments.ts | 29 ++- .../api/cases/comments/delete_comment.ts | 46 ++-- .../api/cases/comments/find_comments.ts | 5 +- .../api/cases/comments/get_all_comment.ts | 3 +- .../routes/api/cases/comments/get_comment.ts | 11 - .../api/cases/comments/patch_comment.ts | 50 ++-- .../routes/api/cases/comments/post_comment.ts | 37 +-- .../api/cases/configure/patch_configure.ts | 3 +- .../api/cases/configure/post_configure.ts | 3 +- .../server/routes/api/cases/delete_cases.ts | 27 ++- .../server/routes/api/cases/find_cases.ts | 39 +++- .../case/server/routes/api/cases/get_case.ts | 5 +- .../case/server/routes/api/cases/helpers.ts | 56 ++++- .../routes/api/cases/patch_cases.test.ts | 6 +- .../server/routes/api/cases/patch_cases.ts | 23 +- .../case/server/routes/api/cases/post_case.ts | 27 ++- .../case/server/routes/api/cases/push_case.ts | 176 ++++++++++++++ .../api/cases/reporters/get_reporters.ts | 3 +- .../routes/api/cases/status/get_status.ts | 5 +- .../server/routes/api/cases/tags/get_tags.ts | 3 +- .../user_actions/get_all_user_actions.ts | 46 ++++ .../plugins/case/server/routes/api/index.ts | 16 +- .../plugins/case/server/routes/api/types.ts | 12 +- .../plugins/case/server/routes/api/utils.ts | 32 ++- .../case/server/saved_object_types/cases.ts | 39 +++- .../server/saved_object_types/comments.ts | 13 ++ .../server/saved_object_types/configure.ts | 9 + .../case/server/saved_object_types/index.ts | 1 + .../server/saved_object_types/user_actions.ts | 47 ++++ x-pack/plugins/case/server/services/index.ts | 50 +++- .../server/services/user_actions/helpers.ts | 195 ++++++++++++++++ .../server/services/user_actions/index.ts | 77 +++++++ 78 files changed, 2875 insertions(+), 438 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/case_view/push_to_service.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts create mode 100644 x-pack/plugins/case/common/api/cases/user_actions.ts create mode 100644 x-pack/plugins/case/server/routes/api/cases/push_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts create mode 100644 x-pack/plugins/case/server/saved_object_types/user_actions.ts create mode 100644 x-pack/plugins/case/server/services/user_actions/helpers.ts create mode 100644 x-pack/plugins/case/server/services/user_actions/index.ts diff --git a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx index 3056b166c1153..20ba0b50f5126 100644 --- a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx +++ b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx @@ -31,6 +31,7 @@ export const RedirectToConfigureCasesPage = () => ( const baseCaseUrl = `#/link-to/${SiemPageName.case}`; export const getCaseUrl = () => baseCaseUrl; -export const getCaseDetailsUrl = (detailName: string) => `${baseCaseUrl}/${detailName}`; -export const getCreateCaseUrl = () => `${baseCaseUrl}/create`; -export const getConfigureCasesUrl = () => `${baseCaseUrl}/configure`; +export const getCaseDetailsUrl = (detailName: string, search: string) => + `${baseCaseUrl}/${detailName}${search}`; +export const getCreateCaseUrl = (search: string) => `${baseCaseUrl}/create${search}`; +export const getConfigureCasesUrl = (search: string) => `${baseCaseUrl}/configure${search}`; diff --git a/x-pack/legacy/plugins/siem/public/components/links/index.tsx b/x-pack/legacy/plugins/siem/public/components/links/index.tsx index 04de0b1d5d3bf..935df9ad3361f 100644 --- a/x-pack/legacy/plugins/siem/public/components/links/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/links/index.tsx @@ -23,8 +23,10 @@ import { import { FlowTarget, FlowTargetSourceDest } from '../../graphql/types'; import { useUiSetting$ } from '../../lib/kibana'; import { IP_REPUTATION_LINKS_SETTING } from '../../../common/constants'; +import { navTabs } from '../../pages/home/home_navigations'; import * as i18n from '../page/network/ip_overview/translations'; import { isUrlInvalid } from '../../pages/detection_engine/rules/components/step_about_rule/helpers'; +import { useGetUrlSearch } from '../navigation/use_get_url_search'; import { ExternalLinkIcon } from '../external_link_icon'; export const DEFAULT_NUMBER_OF_LINK = 5; @@ -89,20 +91,24 @@ export const IPDetailsLink = React.memo(IPDetailsLinkComponent); const CaseDetailsLinkComponent: React.FC<{ children?: React.ReactNode; detailName: string }> = ({ children, detailName, -}) => ( - - {children ? children : detailName} - -); +}) => { + const urlSearch = useGetUrlSearch(navTabs.case); + return ( + + {children ? children : detailName} + + ); +}; export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent); CaseDetailsLink.displayName = 'CaseDetailsLink'; -export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => ( - {children} -)); +export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => { + const urlSearch = useGetUrlSearch(navTabs.case); + return {children}; +}); CreateCaseLink.displayName = 'CreateCaseLink'; diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx index 10aa388449d91..6e957313d9b04 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx @@ -158,7 +158,7 @@ describe('UrlStateContainer', () => { hash: '', pathname: examplePath, search: [CONSTANTS.timelinePage].includes(page) - ? '?timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))' + ? `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))` : `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))`, state: '', }); diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts index 2cb1b0c96ad79..c6f49d8a0e49b 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts @@ -60,8 +60,20 @@ export const URL_STATE_KEYS: Record = { CONSTANTS.timerange, CONSTANTS.timeline, ], - timeline: [CONSTANTS.timeline, CONSTANTS.timerange], - case: [], + timeline: [ + CONSTANTS.appQuery, + CONSTANTS.filters, + CONSTANTS.savedQuery, + CONSTANTS.timeline, + CONSTANTS.timerange, + ], + case: [ + CONSTANTS.appQuery, + CONSTANTS.filters, + CONSTANTS.savedQuery, + CONSTANTS.timeline, + CONSTANTS.timerange, + ], }; export type LocationTypes = diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts index 5ba1f010e0d52..16ee294224bb9 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -13,9 +13,15 @@ import { CommentRequest, CommentResponse, User, + CaseUserActionsResponse, + CaseExternalServiceRequest, + ServiceConnectorCaseParams, + ServiceConnectorCaseResponse, + ActionTypeExecutorResult, } from '../../../../../../plugins/case/common/api'; import { KibanaServices } from '../../lib/kibana'; import { + ActionLicense, AllCases, BulkUpdateStatus, Case, @@ -23,16 +29,20 @@ import { Comment, FetchCasesProps, SortFieldCase, + CaseUserActions, } from './types'; import { CASES_URL } from './constants'; import { convertToCamelCase, convertAllCasesToCamel, + convertArrayToCamelCase, decodeCaseResponse, decodeCasesResponse, decodeCasesFindResponse, decodeCasesStatusResponse, decodeCommentResponse, + decodeCaseUserActionsResponse, + decodeServiceConnectorCaseResponse, } from './utils'; export const getCase = async (caseId: string, includeComments: boolean = true): Promise => { @@ -71,6 +81,20 @@ export const getReporters = async (signal: AbortSignal): Promise => { return response ?? []; }; +export const getCaseUserActions = async ( + caseId: string, + signal: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + `${CASES_URL}/${caseId}/user_actions`, + { + method: 'GET', + signal, + } + ); + return convertArrayToCamelCase(decodeCaseUserActionsResponse(response)) as CaseUserActions[]; +}; + export const getCases = async ({ filterOptions = { search: '', @@ -161,3 +185,43 @@ export const deleteCases = async (caseIds: string[]): Promise => { }); return response === 'true' ? true : false; }; + +export const pushCase = async ( + caseId: string, + push: CaseExternalServiceRequest, + signal: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + `${CASES_URL}/${caseId}/_push`, + { + method: 'POST', + body: JSON.stringify(push), + signal, + } + ); + return convertToCamelCase(decodeCaseResponse(response)); +}; + +export const pushToService = async ( + connectorId: string, + casePushParams: ServiceConnectorCaseParams, + signal: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + `/api/action/${connectorId}/_execute`, + { + method: 'POST', + body: JSON.stringify({ params: casePushParams }), + signal, + } + ); + return decodeServiceConnectorCaseResponse(response.data); +}; + +export const getActionLicense = async (signal: AbortSignal): Promise => { + const response = await KibanaServices.get().http.fetch(`/api/action/types`, { + method: 'GET', + signal, + }); + return response; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts index fc7aaa3643d77..d69c23fe02ec9 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts @@ -26,6 +26,7 @@ export interface CaseConfigure { createdAt: string; createdBy: ElasticUser; connectorId: string; + connectorName: string; closureType: ClosureType; updatedAt: string; updatedBy: ElasticUser; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx index 22ac54093d1dc..a24f8303824c5 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx @@ -13,6 +13,7 @@ import { ClosureType } from './types'; interface PersistCaseConfigure { connectorId: string; + connectorName: string; closureType: ClosureType; } @@ -24,12 +25,12 @@ export interface ReturnUseCaseConfigure { } interface UseCaseConfigure { - setConnectorId: (newConnectorId: string) => void; - setClosureType: (newClosureType: ClosureType) => void; + setConnector: (newConnectorId: string, newConnectorName?: string) => void; + setClosureType?: (newClosureType: ClosureType) => void; } export const useCaseConfigure = ({ - setConnectorId, + setConnector, setClosureType, }: UseCaseConfigure): ReturnUseCaseConfigure => { const [, dispatchToaster] = useStateToaster(); @@ -48,8 +49,10 @@ export const useCaseConfigure = ({ if (!didCancel) { setLoading(false); if (res != null) { - setConnectorId(res.connectorId); - setClosureType(res.closureType); + setConnector(res.connectorId, res.connectorName); + if (setClosureType != null) { + setClosureType(res.closureType); + } setVersion(res.version); } } @@ -74,7 +77,7 @@ export const useCaseConfigure = ({ }, []); const persistCaseConfigure = useCallback( - async ({ connectorId, closureType }: PersistCaseConfigure) => { + async ({ connectorId, connectorName, closureType }: PersistCaseConfigure) => { let didCancel = false; const abortCtrl = new AbortController(); const saveCaseConfiguration = async () => { @@ -83,7 +86,11 @@ export const useCaseConfigure = ({ const res = version.length === 0 ? await postCaseConfigure( - { connector_id: connectorId, closure_type: closureType }, + { + connector_id: connectorId, + connector_name: connectorName, + closure_type: closureType, + }, abortCtrl.signal ) : await patchCaseConfigure( @@ -92,8 +99,10 @@ export const useCaseConfigure = ({ ); if (!didCancel) { setPersistLoading(false); - setConnectorId(res.connectorId); - setClosureType(res.closureType); + setConnector(res.connectorId); + if (setClosureType) { + setClosureType(res.closureType); + } setVersion(res.version); } } catch (error) { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/translations.ts b/x-pack/legacy/plugins/siem/public/containers/case/translations.ts index 0c8b896e2b426..601db373f041e 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/translations.ts @@ -16,3 +16,10 @@ export const TAG_FETCH_FAILURE = i18n.translate( defaultMessage: 'Failed to fetch Tags', } ); + +export const SUCCESS_SEND_TO_EXTERNAL_SERVICE = i18n.translate( + 'xpack.siem.containers.case.pushToExterService', + { + defaultMessage: 'Successfully sent to ServiceNow', + } +); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 44519031e91cb..bbbb13788d53a 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -4,30 +4,53 @@ * you may not use this file except in compliance with the Elastic License. */ -import { User } from '../../../../../../plugins/case/common/api'; +import { User, UserActionField, UserAction } from '../../../../../../plugins/case/common/api'; export interface Comment { id: string; createdAt: string; createdBy: ElasticUser; comment: string; + pushedAt: string | null; + pushedBy: string | null; updatedAt: string | null; updatedBy: ElasticUser | null; version: string; } +export interface CaseUserActions { + actionId: string; + actionField: UserActionField; + action: UserAction; + actionAt: string; + actionBy: ElasticUser; + caseId: string; + commentId: string | null; + newValue: string | null; + oldValue: string | null; +} +export interface CaseExternalService { + pushedAt: string; + pushedBy: string; + connectorId: string; + connectorName: string; + externalId: string; + externalTitle: string; + externalUrl: string; +} export interface Case { id: string; closedAt: string | null; closedBy: ElasticUser | null; comments: Comment[]; - commentIds: string[]; createdAt: string; createdBy: ElasticUser; description: string; + externalService: CaseExternalService | null; status: string; tags: string[]; title: string; + totalComment: number; updatedAt: string | null; updatedBy: ElasticUser | null; version: string; @@ -84,3 +107,10 @@ export interface BulkUpdateStatus { id: string; version: string; } +export interface ActionLicense { + id: string; + name: string; + enabled: boolean; + enabledInConfig: boolean; + enabledInLicense: boolean; +} diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx new file mode 100644 index 0000000000000..12f92b2db039b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx @@ -0,0 +1,74 @@ +/* + * 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 { useCallback, useEffect, useState } from 'react'; + +import { errorToToaster, useStateToaster } from '../../components/toasters'; +import { getActionLicense } from './api'; +import * as i18n from './translations'; +import { ActionLicense } from './types'; + +interface ActionLicenseState { + actionLicense: ActionLicense | null; + isLoading: boolean; + isError: boolean; +} + +const initialData: ActionLicenseState = { + actionLicense: null, + isLoading: true, + isError: false, +}; + +export const useGetActionLicense = (): ActionLicenseState => { + const [actionLicenseState, setActionLicensesState] = useState(initialData); + + const [, dispatchToaster] = useStateToaster(); + + const fetchActionLicense = useCallback(() => { + let didCancel = false; + const abortCtrl = new AbortController(); + const fetchData = async () => { + setActionLicensesState({ + ...actionLicenseState, + isLoading: true, + }); + try { + const response = await getActionLicense(abortCtrl.signal); + if (!didCancel) { + setActionLicensesState({ + actionLicense: response.find(l => l.id === '.servicenow') ?? null, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + setActionLicensesState({ + actionLicense: null, + isLoading: false, + isError: true, + }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + abortCtrl.abort(); + }; + }, [actionLicenseState]); + + useEffect(() => { + fetchActionLicense(); + }, []); + return { ...actionLicenseState }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index b70195e2c126f..02b41c9fc720f 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -53,14 +53,15 @@ const initialData: Case = { closedBy: null, createdAt: '', comments: [], - commentIds: [], createdBy: { username: '', }, description: '', + externalService: null, status: '', tags: [], title: '', + totalComment: 0, updatedAt: null, updatedBy: null, version: '', diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx new file mode 100644 index 0000000000000..4c278bc038134 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx @@ -0,0 +1,126 @@ +/* + * 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 { isEmpty, uniqBy } from 'lodash/fp'; +import { useCallback, useEffect, useState } from 'react'; + +import { errorToToaster, useStateToaster } from '../../components/toasters'; +import { getCaseUserActions } from './api'; +import * as i18n from './translations'; +import { CaseUserActions, ElasticUser } from './types'; + +interface CaseUserActionsState { + caseUserActions: CaseUserActions[]; + firstIndexPushToService: number; + hasDataToPush: boolean; + participants: ElasticUser[]; + isLoading: boolean; + isError: boolean; + lastIndexPushToService: number; +} + +const initialData: CaseUserActionsState = { + caseUserActions: [], + firstIndexPushToService: -1, + lastIndexPushToService: -1, + hasDataToPush: false, + isLoading: true, + isError: false, + participants: [], +}; + +interface UseGetCaseUserActions extends CaseUserActionsState { + fetchCaseUserActions: (caseId: string) => void; +} + +const getPushedInfo = ( + caseUserActions: CaseUserActions[] +): { firstIndexPushToService: number; lastIndexPushToService: number; hasDataToPush: boolean } => { + const firstIndexPushToService = caseUserActions.findIndex( + cua => cua.action === 'push-to-service' + ); + const lastIndexPushToService = caseUserActions + .map(cua => cua.action) + .lastIndexOf('push-to-service'); + + const hasDataToPush = + lastIndexPushToService === -1 || lastIndexPushToService < caseUserActions.length - 1; + return { + firstIndexPushToService, + lastIndexPushToService, + hasDataToPush, + }; +}; + +export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => { + const [caseUserActionsState, setCaseUserActionsState] = useState( + initialData + ); + + const [, dispatchToaster] = useStateToaster(); + + const fetchCaseUserActions = useCallback( + (thisCaseId: string) => { + let didCancel = false; + const abortCtrl = new AbortController(); + const fetchData = async () => { + setCaseUserActionsState({ + ...caseUserActionsState, + isLoading: true, + }); + try { + const response = await getCaseUserActions(thisCaseId, abortCtrl.signal); + if (!didCancel) { + // Attention Future developer + // We are removing the first item because it will always be the creation of the case + // and we do not want it to simplify our life + const participants = !isEmpty(response) + ? uniqBy('actionBy.username', response).map(cau => cau.actionBy) + : []; + const caseUserActions = !isEmpty(response) ? response.slice(1) : []; + setCaseUserActionsState({ + caseUserActions, + ...getPushedInfo(caseUserActions), + isLoading: false, + isError: false, + participants, + }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + setCaseUserActionsState({ + caseUserActions: [], + firstIndexPushToService: -1, + lastIndexPushToService: -1, + hasDataToPush: false, + isLoading: false, + isError: true, + participants: [], + }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + abortCtrl.abort(); + }; + }, + [caseUserActionsState] + ); + + useEffect(() => { + if (!isEmpty(caseId)) { + fetchCaseUserActions(caseId); + } + }, [caseId]); + return { ...caseUserActionsState, fetchCaseUserActions }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx new file mode 100644 index 0000000000000..b6fb15f4fa083 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useReducer, useCallback } from 'react'; + +import { + ServiceConnectorCaseResponse, + ServiceConnectorCaseParams, +} from '../../../../../../plugins/case/common/api'; +import { errorToToaster, useStateToaster, displaySuccessToast } from '../../components/toasters'; + +import { getCase, pushToService, pushCase } from './api'; +import * as i18n from './translations'; +import { Case } from './types'; + +interface PushToServiceState { + serviceData: ServiceConnectorCaseResponse | null; + pushedCaseData: Case | null; + isLoading: boolean; + isError: boolean; +} +type Action = + | { type: 'FETCH_INIT' } + | { type: 'FETCH_SUCCESS_PUSH_SERVICE'; payload: ServiceConnectorCaseResponse | null } + | { type: 'FETCH_SUCCESS_PUSH_CASE'; payload: Case | null } + | { type: 'FETCH_FAILURE' }; + +const dataFetchReducer = (state: PushToServiceState, action: Action): PushToServiceState => { + switch (action.type) { + case 'FETCH_INIT': + return { + ...state, + isLoading: true, + isError: false, + }; + case 'FETCH_SUCCESS_PUSH_SERVICE': + return { + ...state, + isLoading: false, + isError: false, + serviceData: action.payload ?? null, + }; + case 'FETCH_SUCCESS_PUSH_CASE': + return { + ...state, + isLoading: false, + isError: false, + pushedCaseData: action.payload ?? null, + }; + case 'FETCH_FAILURE': + return { + ...state, + isLoading: false, + isError: true, + }; + default: + return state; + } +}; + +interface PushToServiceRequest { + caseId: string; + connectorId: string; + connectorName: string; + updateCase: (newCase: Case) => void; +} + +interface UsePostPushToService extends PushToServiceState { + postPushToService: ({ caseId, connectorId, updateCase }: PushToServiceRequest) => void; +} + +export const usePostPushToService = (): UsePostPushToService => { + const [state, dispatch] = useReducer(dataFetchReducer, { + serviceData: null, + pushedCaseData: null, + isLoading: false, + isError: false, + }); + const [, dispatchToaster] = useStateToaster(); + + const postPushToService = useCallback( + async ({ caseId, connectorId, connectorName, updateCase }: PushToServiceRequest) => { + let cancel = false; + const abortCtrl = new AbortController(); + try { + dispatch({ type: 'FETCH_INIT' }); + const casePushData = await getCase(caseId); + const responseService = await pushToService( + connectorId, + formatServiceRequestData(casePushData), + abortCtrl.signal + ); + const responseCase = await pushCase( + caseId, + { + connector_id: connectorId, + connector_name: connectorName, + external_id: responseService.incidentId, + external_title: responseService.number, + external_url: responseService.url, + }, + abortCtrl.signal + ); + if (!cancel) { + dispatch({ type: 'FETCH_SUCCESS_PUSH_SERVICE', payload: responseService }); + dispatch({ type: 'FETCH_SUCCESS_PUSH_CASE', payload: responseCase }); + updateCase(responseCase); + displaySuccessToast(i18n.SUCCESS_SEND_TO_EXTERNAL_SERVICE, dispatchToaster); + } + } catch (error) { + if (!cancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + dispatch({ type: 'FETCH_FAILURE' }); + } + } + return () => { + cancel = true; + abortCtrl.abort(); + }; + }, + [] + ); + + return { ...state, postPushToService }; +}; + +const formatServiceRequestData = (myCase: Case): ServiceConnectorCaseParams => { + const { + id: caseId, + createdAt, + createdBy, + comments, + description, + externalService, + title, + updatedAt, + updatedBy, + } = myCase; + + return { + caseId, + createdAt, + createdBy: { + fullName: createdBy.fullName ?? null, + username: createdBy?.username, + }, + comments: comments.map(c => ({ + commentId: c.id, + comment: c.comment, + createdAt: c.createdAt, + createdBy: { + fullName: c.createdBy.fullName ?? null, + username: c.createdBy.username, + }, + updatedAt: c.updatedAt, + updatedBy: + c.updatedBy != null + ? { + fullName: c.updatedBy.fullName ?? null, + username: c.updatedBy.username, + } + : null, + })), + description, + incidentId: externalService?.externalId ?? null, + title, + updatedAt, + updatedBy: + updatedBy != null + ? { + fullName: updatedBy.fullName ?? null, + username: updatedBy.username, + } + : null, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index 987620469901b..f8af088f7e03b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -25,6 +25,7 @@ interface NewCaseState { export interface UpdateByKey { updateKey: UpdateKey; updateValue: CaseRequest[UpdateKey]; + fetchCaseUserActions?: (caseId: string) => void; } type Action = @@ -64,6 +65,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => interface UseUpdateCase extends NewCaseState { updateCaseProperty: (updates: UpdateByKey) => void; + updateCase: (newCase: Case) => void; } export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase => { const [state, dispatch] = useReducer(dataFetchReducer, { @@ -74,8 +76,12 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase }); const [, dispatchToaster] = useStateToaster(); + const updateCase = useCallback((newCase: Case) => { + dispatch({ type: 'FETCH_SUCCESS', payload: newCase }); + }, []); + const dispatchUpdateCaseProperty = useCallback( - async ({ updateKey, updateValue }: UpdateByKey) => { + async ({ fetchCaseUserActions, updateKey, updateValue }: UpdateByKey) => { let cancel = false; try { dispatch({ type: 'FETCH_INIT', payload: updateKey }); @@ -85,6 +91,9 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase state.caseData.version ); if (!cancel) { + if (fetchCaseUserActions != null) { + fetchCaseUserActions(caseId); + } dispatch({ type: 'FETCH_SUCCESS', payload: response[0] }); } } catch (error) { @@ -104,5 +113,5 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase [state] ); - return { ...state, updateCaseProperty: dispatchUpdateCaseProperty }; + return { ...state, updateCase, updateCaseProperty: dispatchUpdateCaseProperty }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx index a40a1100ca735..c1b2bfde30126 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx @@ -70,8 +70,15 @@ const dataFetchReducer = (state: CommentUpdateState, action: Action): CommentUpd } }; +interface UpdateComment { + caseId: string; + commentId: string; + commentUpdate: string; + fetchUserActions: () => void; +} + interface UseUpdateComment extends CommentUpdateState { - updateComment: (caseId: string, commentId: string, commentUpdate: string) => void; + updateComment: ({ caseId, commentId, commentUpdate, fetchUserActions }: UpdateComment) => void; addPostedComment: Dispatch; } @@ -84,7 +91,7 @@ export const useUpdateComment = (comments: Comment[]): UseUpdateComment => { const [, dispatchToaster] = useStateToaster(); const dispatchUpdateComment = useCallback( - async (caseId: string, commentId: string, commentUpdate: string) => { + async ({ caseId, commentId, commentUpdate, fetchUserActions }: UpdateComment) => { let cancel = false; try { dispatch({ type: 'FETCH_INIT', payload: commentId }); @@ -98,6 +105,7 @@ export const useUpdateComment = (comments: Comment[]): UseUpdateComment => { currentComment.version ); if (!cancel) { + fetchUserActions(); dispatch({ type: 'FETCH_SUCCESS', payload: { update: response, commentId } }); } } catch (error) { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts index 8f24d5a435240..ce23ac6c440b6 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts @@ -23,6 +23,10 @@ import { CommentResponseRt, CasesConfigureResponse, CaseConfigureResponseRt, + CaseUserActionsResponse, + CaseUserActionsResponseRt, + ServiceConnectorCaseResponseRt, + ServiceConnectorCaseResponse, } from '../../../../../../plugins/case/common/api'; import { ToasterError } from '../../components/toasters'; import { AllCases, Case } from './types'; @@ -86,3 +90,15 @@ export const decodeCaseConfigureResponse = (respCase?: CasesConfigureResponse) = CaseConfigureResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity) ); + +export const decodeCaseUserActionsResponse = (respUserActions?: CaseUserActionsResponse) => + pipe( + CaseUserActionsResponseRt.decode(respUserActions), + fold(throwErrors(createToasterPlainError), identity) + ); + +export const decodeServiceConnectorCaseResponse = (respPushCase?: ServiceConnectorCaseResponse) => + pipe( + ServiceConnectorCaseResponseRt.decode(respPushCase), + fold(throwErrors(createToasterPlainError), identity) + ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx index 0b3b0daaf4bbc..836595c7c45d9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx @@ -30,72 +30,79 @@ const initialCommentValue: CommentRequest = { interface AddCommentProps { caseId: string; + onCommentSaving?: () => void; onCommentPosted: (commentResponse: Comment) => void; + showLoading?: boolean; } -export const AddComment = React.memo(({ caseId, onCommentPosted }) => { - const { commentData, isLoading, postComment, resetCommentData } = usePostComment(caseId); - const { form } = useForm({ - defaultValue: initialCommentValue, - options: { stripEmptyFields: false }, - schema, - }); - const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( - form, - 'comment' - ); +export const AddComment = React.memo( + ({ caseId, showLoading = true, onCommentPosted, onCommentSaving }) => { + const { commentData, isLoading, postComment, resetCommentData } = usePostComment(caseId); + const { form } = useForm({ + defaultValue: initialCommentValue, + options: { stripEmptyFields: false }, + schema, + }); + const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( + form, + 'comment' + ); - useEffect(() => { - if (commentData !== null) { - onCommentPosted(commentData); - form.reset(); - resetCommentData(); - } - }, [commentData]); + useEffect(() => { + if (commentData !== null) { + onCommentPosted(commentData); + form.reset(); + resetCommentData(); + } + }, [commentData]); - const onSubmit = useCallback(async () => { - const { isValid, data } = await form.submit(); - if (isValid) { - await postComment(data); - } - }, [form]); + const onSubmit = useCallback(async () => { + const { isValid, data } = await form.submit(); + if (isValid) { + if (onCommentSaving != null) { + onCommentSaving(); + } + await postComment(data); + } + }, [form]); - return ( - <> - {isLoading && } -
- - {i18n.ADD_COMMENT} - - ), - topRightContent: ( - - ), - }} - /> - - - ); -}); + return ( + <> + {isLoading && showLoading && } +
+ + {i18n.ADD_COMMENT} + + ), + topRightContent: ( + + ), + }} + /> + + + ); + } +); AddComment.displayName = 'AddComment'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 48fbb4e74c407..d4ec32dfd070b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -18,12 +18,13 @@ export const useGetCasesMockState: UseGetCasesState = { id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:23.627Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['defacement'], title: 'Another horrible breach', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', @@ -34,12 +35,13 @@ export const useGetCasesMockState: UseGetCasesState = { id: '362a5c10-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:13.328Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['phishing'], title: 'Bad email', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', @@ -50,12 +52,13 @@ export const useGetCasesMockState: UseGetCasesState = { id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:11.328Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['phishing'], title: 'Bad email', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', @@ -66,14 +69,15 @@ export const useGetCasesMockState: UseGetCasesState = { id: '31890e90-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:05.563Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'closed', tags: ['phishing'], title: 'Uh oh', - updatedAt: '2020-02-13T19:44:13.328Z', - updatedBy: { username: 'elastic' }, + totalComment: 0, + updatedAt: null, + updatedBy: null, version: 'WzQ3LDFd', }, { @@ -82,12 +86,13 @@ export const useGetCasesMockState: UseGetCasesState = { id: '2f5b3210-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:01.901Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['phishing'], title: 'Uh oh', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index b9e1113c486ad..32a29483e9c75 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -35,6 +35,7 @@ const Spacer = styled.span` const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); + export const getCasesColumns = ( actions: Array>, filterStatus: string @@ -108,11 +109,11 @@ export const getCasesColumns = ( }, { align: 'right', - field: 'commentIds', + field: 'totalComment', name: i18n.COMMENTS, sortable: true, - render: (comments: Case['commentIds']) => - renderStringField(`${comments.length}`, `case-table-column-commentCount`), + render: (totalComment: Case['totalComment']) => + renderStringField(`${totalComment}`, `case-table-column-commentCount`), }, filterStatus === 'open' ? { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx index 13869c79c45fd..bdcb87b483851 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx @@ -95,7 +95,9 @@ describe('AllCases', () => { .find(`a[data-test-subj="case-details-link"]`) .first() .prop('href') - ).toEqual(`#/link-to/case/${useGetCasesMockState.data.cases[0].id}`); + ).toEqual( + `#/link-to/case/${useGetCasesMockState.data.cases[0].id}?timerange=(global:(linkTo:!(timeline),timerange:(from:0,fromStr:now-24h,kind:relative,to:1,toStr:now)),timeline:(linkTo:!(global),timerange:(from:0,fromStr:now-24h,kind:relative,to:1,toStr:now)))` + ); expect( wrapper .find(`a[data-test-subj="case-details-link"]`) diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index e7e1e624ccba2..87a2ea888831a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -35,7 +35,9 @@ import { UtilityBarText, } from '../../../../components/utility_bar'; import { getConfigureCasesUrl, getCreateCaseUrl } from '../../../../components/link_to'; - +import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; +import { navTabs } from '../../../home/home_navigations'; import { getBulkItems } from '../bulk_actions'; import { CaseHeaderPage } from '../case_header_page'; import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; @@ -43,10 +45,6 @@ import { OpenClosedStats } from '../open_closed_stats'; import { getActions } from './actions'; import { CasesTableFilters } from './table_filters'; -import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; - -const CONFIGURE_CASES_URL = getConfigureCasesUrl(); -const CREATE_CASE_URL = getCreateCaseUrl(); const Div = styled.div` margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; @@ -78,6 +76,7 @@ const getSortField = (field: string): SortFieldCase => { return SortFieldCase.createdAt; }; export const AllCases = React.memo(() => { + const urlSearch = useGetUrlSearch(navTabs.case); const { countClosedCases, countOpenCases, @@ -276,12 +275,12 @@ export const AllCases = React.memo(() => { /> - + {i18n.CONFIGURE_CASES_BUTTON} - + {i18n.CREATE_TITLE} @@ -342,7 +341,12 @@ export const AllCases = React.memo(() => { titleSize="xs" body={i18n.NO_CASES_BODY} actions={ - + {i18n.ADD_NEW_CASE} } diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx index 9dbd71ea3e34c..0420a71fea907 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; +import React from 'react'; import styled, { css } from 'styled-components'; import { EuiBadge, @@ -39,7 +39,7 @@ interface CaseStatusProps { isSelected: boolean; status: string; title: string; - toggleStatusCase: (status: string) => void; + toggleStatusCase: (evt: unknown) => void; value: string | null; } const CaseStatusComp: React.FC = ({ @@ -55,51 +55,46 @@ const CaseStatusComp: React.FC = ({ title, toggleStatusCase, value, -}) => { - const onChange = useCallback(e => toggleStatusCase(e.target.checked ? 'closed' : 'open'), [ - toggleStatusCase, - ]); - return ( - - - - - - {i18n.STATUS} - - - {status} - - - - - {title} - - - - - - - - - - - - +}) => ( + + + + - + {i18n.STATUS} + + + {status} + + + + + {title} + + + - - - ); -}; + + + + + + + + + + + + + +); export const CaseStatus = React.memo(CaseStatusComp); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index e11441eac3a9d..7aadea1a453a7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -13,7 +13,6 @@ export const caseProps: CaseProps = { closedAt: null, closedBy: null, id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', - commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'], comments: [ { comment: 'Solve this fast!', @@ -24,6 +23,8 @@ export const caseProps: CaseProps = { username: 'smilovic', email: 'notmyrealemailfool@elastic.co', }, + pushedAt: null, + pushedBy: null, updatedAt: '2020-02-20T23:06:33.798Z', updatedBy: { username: 'elastic', @@ -34,9 +35,11 @@ export const caseProps: CaseProps = { createdAt: '2020-02-13T19:44:23.627Z', createdBy: { fullName: null, email: 'testemail@elastic.co', username: 'elastic' }, description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['defacement'], title: 'Another horrible breach!!', + totalComment: 1, updatedAt: '2020-02-19T15:02:57.995Z', updatedBy: { username: 'elastic', @@ -44,6 +47,7 @@ export const caseProps: CaseProps = { version: 'WzQ3LDFd', }, }; + export const caseClosedProps: CaseProps = { ...caseProps, initialData: { @@ -63,3 +67,21 @@ export const data: Case = { export const dataClosed: Case = { ...caseClosedProps.initialData, }; + +export const caseUserActions = [ + { + actionField: ['comment'], + action: 'create', + actionAt: '2020-03-20T17:10:09.814Z', + actionBy: { + fullName: 'Steph Milovic', + username: 'smilovic', + email: 'notmyrealemailfool@elastic.co', + }, + newValue: 'Solve this fast!', + oldValue: null, + actionId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + caseId: '9b833a50-6acd-11ea-8fad-af86b1071bd9', + commentId: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index 3f4a83d1bff33..18cc33d8a6d4d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -11,11 +11,18 @@ import { mount } from 'enzyme'; import routeData from 'react-router'; /* eslint-enable @kbn/eslint/module_migration */ import { CaseComponent } from './'; -import { caseProps, caseClosedProps, data, dataClosed } from './__mock__'; +import { caseProps, caseClosedProps, data, dataClosed, caseUserActions } from './__mock__'; import { TestProviders } from '../../../../mock'; import { useUpdateCase } from '../../../../containers/case/use_update_case'; +import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; +import { wait } from '../../../../lib/helpers'; +import { usePushToService } from './push_to_service'; jest.mock('../../../../containers/case/use_update_case'); +jest.mock('../../../../containers/case/use_get_case_user_actions'); +jest.mock('./push_to_service'); const useUpdateCaseMock = useUpdateCase as jest.Mock; +const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock; +const usePushToServiceMock = usePushToService as jest.Mock; type Action = 'PUSH' | 'POP' | 'REPLACE'; const pop: Action = 'POP'; const location = { @@ -47,6 +54,7 @@ const mockLocation = { describe('CaseView ', () => { const updateCaseProperty = jest.fn(); + const fetchCaseUserActions = jest.fn(); /* eslint-disable no-console */ // Silence until enzyme fixed to use ReactTestUtils.act() const originalError = console.error; @@ -66,13 +74,31 @@ describe('CaseView ', () => { updateCaseProperty, }; + const defaultUseGetCaseUserActions = { + caseUserActions, + fetchCaseUserActions, + firstIndexPushToService: -1, + hasDataToPush: false, + isLoading: false, + isError: false, + lastIndexPushToService: -1, + participants: [data.createdBy], + }; + + const defaultUsePushToServiceMock = { + pushButton: <>{'Hello Button'}, + pushCallouts: null, + }; + beforeEach(() => { jest.resetAllMocks(); useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState); jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); + useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions); + usePushToServiceMock.mockImplementation(() => defaultUsePushToServiceMock); }); - it('should render CaseComponent', () => { + it('should render CaseComponent', async () => { const wrapper = mount( @@ -80,6 +106,7 @@ describe('CaseView ', () => { ); + await wait(); expect( wrapper .find(`[data-test-subj="case-view-title"]`) @@ -119,7 +146,7 @@ describe('CaseView ', () => { ).toEqual(data.description); }); - it('should show closed indicators in header when case is closed', () => { + it('should show closed indicators in header when case is closed', async () => { useUpdateCaseMock.mockImplementation(() => ({ ...defaultUpdateCaseState, caseData: dataClosed, @@ -131,6 +158,7 @@ describe('CaseView ', () => { ); + await wait(); expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false); expect( wrapper @@ -146,7 +174,7 @@ describe('CaseView ', () => { ).toEqual(dataClosed.status); }); - it('should dispatch update state when button is toggled', () => { + it('should dispatch update state when button is toggled', async () => { const wrapper = mount( @@ -154,18 +182,19 @@ describe('CaseView ', () => { ); - + await wait(); wrapper .find('input[data-test-subj="toggle-case-status"]') .simulate('change', { target: { checked: true } }); expect(updateCaseProperty).toBeCalledWith({ + fetchCaseUserActions, updateKey: 'status', updateValue: 'closed', }); }); - it('should render comments', () => { + it('should render comments', async () => { const wrapper = mount( @@ -173,6 +202,7 @@ describe('CaseView ', () => { ); + await wait(); expect( wrapper .find( diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 0ac3adeb860ff..742921cb9f69e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -4,10 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useMemo } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; - +import { + EuiButtonToggle, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingContent, + EuiLoadingSpinner, + EuiHorizontalRule, +} from '@elastic/eui'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; + import * as i18n from './translations'; import { Case } from '../../../../containers/case/types'; import { getCaseUrl } from '../../../../components/link_to'; @@ -24,6 +31,8 @@ import { WhitePageWrapper } from '../wrappers'; import { useBasePath } from '../../../../lib/kibana'; import { CaseStatus } from '../case_status'; import { SpyRoute } from '../../../../utils/route/spy_routes'; +import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; +import { usePushToService } from './push_to_service'; interface Props { caseId: string; @@ -37,6 +46,13 @@ const MyEuiFlexGroup = styled(EuiFlexGroup)` height: 100%; `; +const MyEuiHorizontalRule = styled(EuiHorizontalRule)` + margin-left: 48px; + &.euiHorizontalRule--full { + width: calc(100% - 48px); + } +`; + export interface CaseProps { caseId: string; initialData: Case; @@ -45,7 +61,20 @@ export interface CaseProps { export const CaseComponent = React.memo(({ caseId, initialData }) => { const basePath = window.location.origin + useBasePath(); const caseLink = `${basePath}/app/siem#/case/${caseId}`; - const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData); + const [initLoadingData, setInitLoadingData] = useState(true); + const { + caseUserActions, + fetchCaseUserActions, + firstIndexPushToService, + hasDataToPush, + isLoading: isLoadingUserActions, + lastIndexPushToService, + participants, + } = useGetCaseUserActions(caseId); + const { caseData, isLoading, updateKey, updateCase, updateCaseProperty } = useUpdateCase( + caseId, + initialData + ); // Update Fields const onUpdateField = useCallback( @@ -55,6 +84,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const titleUpdate = getTypedPayload(updateValue); if (titleUpdate.length > 0) { updateCaseProperty({ + fetchCaseUserActions, updateKey: 'title', updateValue: titleUpdate, }); @@ -64,6 +94,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const descriptionUpdate = getTypedPayload(updateValue); if (descriptionUpdate.length > 0) { updateCaseProperty({ + fetchCaseUserActions, updateKey: 'description', updateValue: descriptionUpdate, }); @@ -72,6 +103,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => case 'tags': const tagsUpdate = getTypedPayload(updateValue); updateCaseProperty({ + fetchCaseUserActions, updateKey: 'tags', updateValue: tagsUpdate, }); @@ -80,6 +112,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const statusUpdate = getTypedPayload(updateValue); if (caseData.status !== updateValue) { updateCaseProperty({ + fetchCaseUserActions, updateKey: 'status', updateValue: statusUpdate, }); @@ -88,12 +121,29 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => return null; } }, - [caseData.status] + [fetchCaseUserActions, updateCaseProperty, caseData.status] + ); + const handleUpdateCase = useCallback( + (newCase: Case) => { + updateCase(newCase); + fetchCaseUserActions(newCase.id); + }, + [updateCase, fetchCaseUserActions] ); + + const { pushButton, pushCallouts } = usePushToService({ + caseId: caseData.id, + caseStatus: caseData.status, + isNew: caseUserActions.filter(cua => cua.action === 'push-to-service').length === 0, + updateCase: handleUpdateCase, + }); + const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]); const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); - const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]); - + const toggleStatusCase = useCallback( + e => onUpdateField('status', e.target.checked ? 'closed' : 'open'), + [onUpdateField] + ); const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]); const caseStatusData = useMemo( @@ -111,7 +161,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => } : { 'data-test-subj': 'case-view-closedAt', - value: caseData.closedAt, + value: caseData.closedAt ?? '', title: i18n.CASE_CLOSED, buttonLabel: i18n.REOPEN_CASE, status: caseData.status, @@ -126,8 +176,15 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => subject: i18n.EMAIL_SUBJECT(caseData.title), body: i18n.EMAIL_BODY(caseLink), }), - [caseData.title] + [caseLink, caseData.title] ); + + useEffect(() => { + if (initLoadingData && !isLoadingUserActions) { + setInitLoadingData(false); + } + }, [initLoadingData, isLoadingUserActions]); + return ( <> @@ -157,21 +214,52 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => + {pushCallouts != null && pushCallouts} - + {initLoadingData && } + {!initLoadingData && ( + <> + + + + + + + {hasDataToPush && {pushButton}} + + + )} + void; +} + +interface Connector { + connectorId: string; + connectorName: string; +} + +interface ReturnUsePushToService { + pushButton: JSX.Element; + pushCallouts: JSX.Element | null; +} + +export const usePushToService = ({ + caseId, + caseStatus, + updateCase, + isNew, +}: UsePushToService): ReturnUsePushToService => { + const urlSearch = useGetUrlSearch(navTabs.case); + const [connector, setConnector] = useState(null); + + const { isLoading, postPushToService } = usePostPushToService(); + + const handleSetConnector = useCallback((connectorId: string, connectorName?: string) => { + setConnector({ connectorId, connectorName: connectorName ?? '' }); + }, []); + + const { loading: loadingCaseConfigure } = useCaseConfigure({ + setConnector: handleSetConnector, + }); + + const { isLoading: loadingLicense, actionLicense } = useGetActionLicense(); + + const handlePushToService = useCallback(() => { + if (connector != null) { + postPushToService({ + caseId, + ...connector, + updateCase, + }); + } + }, [caseId, connector, postPushToService, updateCase]); + + const errorsMsg = useMemo(() => { + let errors: Array<{ title: string; description: JSX.Element }> = []; + if (actionLicense != null && !actionLicense.enabledInLicense) { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BY_LICENSE_TITLE, + description: ( + + {i18n.LINK_CLOUD_DEPLOYMENT} + + ), + }} + /> + ), + }, + ]; + } + if (connector == null && !loadingCaseConfigure && !loadingLicense) { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE, + description: ( + + {i18n.LINK_CONNECTOR_CONFIGURE} + + ), + }} + /> + ), + }, + ]; + } + if (caseStatus === 'closed') { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE, + description: ( + + ), + }, + ]; + } + if (actionLicense != null && !actionLicense.enabledInConfig) { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE, + description: ( + + {'coming soon...'} + + ), + }} + /> + ), + }, + ]; + } + return errors; + }, [actionLicense, caseStatus, connector, loadingCaseConfigure, loadingLicense, urlSearch]); + + const pushToServiceButton = useMemo( + () => ( + 0} + isLoading={isLoading} + > + {isNew ? i18n.PUSH_SERVICENOW : i18n.UPDATE_PUSH_SERVICENOW} + + ), + [isNew, handlePushToService, isLoading, loadingLicense, loadingCaseConfigure, errorsMsg] + ); + + const objToReturn = useMemo( + () => ({ + pushButton: + errorsMsg.length > 0 ? ( + {errorsMsg[0].description}

} + > + {pushToServiceButton} +
+ ) : ( + <>{pushToServiceButton} + ), + pushCallouts: errorsMsg.length > 0 ? : null, + }), + [errorsMsg, pushToServiceButton] + ); + return objToReturn; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index e5fa3bff51f85..beba80ccd934c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -18,17 +18,40 @@ export const SHOWING_CASES = (actionDate: string, actionName: string, userName: defaultMessage: '{userName} {actionName} on {actionDate}', }); -export const ADDED_DESCRIPTION = i18n.translate( - 'xpack.siem.case.caseView.actionLabel.addDescription', +export const ADDED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.addedField', { + defaultMessage: 'added', +}); + +export const CHANGED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.changededField', { + defaultMessage: 'changed', +}); + +export const EDITED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.editedField', { + defaultMessage: 'edited', +}); + +export const REMOVED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.removedField', { + defaultMessage: 'removed', +}); + +export const PUSHED_NEW_INCIDENT = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.pushedNewIncident', { - defaultMessage: 'added description', + defaultMessage: 'pushed as new incident', + } +); + +export const UPDATE_INCIDENT = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.updateIncident', + { + defaultMessage: 'updated incident', } ); -export const EDITED_DESCRIPTION = i18n.translate( - 'xpack.siem.case.caseView.actionLabel.editDescription', +export const ADDED_DESCRIPTION = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.addDescription', { - defaultMessage: 'edited description', + defaultMessage: 'added description', } ); @@ -52,6 +75,14 @@ export const STATUS = i18n.translate('xpack.siem.case.caseView.statusLabel', { defaultMessage: 'Status', }); +export const CASE = i18n.translate('xpack.siem.case.caseView.case', { + defaultMessage: 'case', +}); + +export const COMMENT = i18n.translate('xpack.siem.case.caseView.comment', { + defaultMessage: 'comment', +}); + export const CASE_OPENED = i18n.translate('xpack.siem.case.caseView.caseOpened', { defaultMessage: 'Case opened', }); @@ -71,3 +102,56 @@ export const EMAIL_BODY = (caseUrl: string) => values: { caseUrl }, defaultMessage: 'Case reference: {caseUrl}', }); + +export const PUSH_SERVICENOW = i18n.translate('xpack.siem.case.caseView.pushAsServicenowIncident', { + defaultMessage: 'Push as ServiceNow incident', +}); + +export const UPDATE_PUSH_SERVICENOW = i18n.translate( + 'xpack.siem.case.caseView.updatePushAsServicenowIncident', + { + defaultMessage: 'Update ServiceNow incident', + } +); + +export const PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigTitle', + { + defaultMessage: 'Configure external connector', + } +); + +export const PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableBecauseCaseClosedTitle', + { + defaultMessage: 'Reopen the case', + } +); + +export const PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByConfigTitle', + { + defaultMessage: 'Enable ServiceNow in Kibana configuration file', + } +); + +export const PUSH_DISABLE_BY_LICENSE_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByLicenseTitle', + { + defaultMessage: 'Upgrade to Elastic Platinum', + } +); + +export const LINK_CLOUD_DEPLOYMENT = i18n.translate( + 'xpack.siem.case.caseView.cloudDeploymentLink', + { + defaultMessage: 'cloud deployment', + } +); + +export const LINK_CONNECTOR_CONFIGURE = i18n.translate( + 'xpack.siem.case.caseView.connectorConfigureLink', + { + defaultMessage: 'connector', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx index c8ef6e32595d0..fb4d91492c1d4 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx @@ -113,7 +113,7 @@ const ConfigureCasesComponent: React.FC = () => { }, []); const { loading: loadingCaseConfigure, persistLoading, persistCaseConfigure } = useCaseConfigure({ - setConnectorId, + setConnector: setConnectorId, setClosureType, }); const { loading: isLoadingConnectors, connectors, refetchConnectors } = useConnectors(); @@ -128,9 +128,13 @@ const ConfigureCasesComponent: React.FC = () => { // TO DO give a warning/error to user when field are not mapped so they have chance to do it () => { setActionBarVisible(false); - persistCaseConfigure({ connectorId, closureType }); + persistCaseConfigure({ + connectorId, + connectorName: connectors.find(c => c.id === connectorId)?.name ?? '', + closureType, + }); }, - [connectorId, closureType, mapping] + [connectorId, connectors, closureType, mapping] ); const onChangeConnector = useCallback((newConnectorId: string) => { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx new file mode 100644 index 0000000000000..15b50e4b4cd8d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx @@ -0,0 +1,33 @@ +/* + * 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 { EuiCallOut, EuiButton, EuiDescriptionList, EuiSpacer } from '@elastic/eui'; +import React, { memo, useCallback, useState } from 'react'; + +import * as i18n from './translations'; + +interface ErrorsPushServiceCallOut { + errors: Array<{ title: string; description: JSX.Element }>; +} + +const ErrorsPushServiceCallOutComponent = ({ errors }: ErrorsPushServiceCallOut) => { + const [showCallOut, setShowCallOut] = useState(true); + const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]); + + return showCallOut ? ( + <> + + + + {i18n.DISMISS_CALLOUT} + + + + + ) : null; +}; + +export const ErrorsPushServiceCallOut = memo(ErrorsPushServiceCallOutComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts new file mode 100644 index 0000000000000..57712e720f6d0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_PUSH_SERVICE_CALLOUT_TITLE = i18n.translate( + 'xpack.siem.case.errorsPushServiceCallOutTitle', + { + defaultMessage: 'To send cases to external systems, you need to:', + } +); + +export const DISMISS_CALLOUT = i18n.translate( + 'xpack.siem.case.dismissErrorsPushServiceCallOutTitle', + { + defaultMessage: 'Dismiss', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx new file mode 100644 index 0000000000000..008f4d7048f56 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiLink } from '@elastic/eui'; +import React from 'react'; + +import { CaseFullExternalService } from '../../../../../../../../plugins/case/common/api'; +import { CaseUserActions } from '../../../../containers/case/types'; +import * as i18n from '../case_view/translations'; + +interface LabelTitle { + action: CaseUserActions; + field: string; + firstIndexPushToService: number; + index: number; +} + +export const getLabelTitle = ({ action, field, firstIndexPushToService, index }: LabelTitle) => { + if (field === 'tags') { + return getTagsLabelTitle(action); + } else if (field === 'title' && action.action === 'update') { + return `${i18n.CHANGED_FIELD.toLowerCase()} ${i18n.CASE_NAME.toLowerCase()} ${i18n.TO} "${ + action.newValue + }"`; + } else if (field === 'description' && action.action === 'update') { + return `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`; + } else if (field === 'status' && action.action === 'update') { + return `${ + action.newValue === 'open' ? i18n.REOPENED_CASE.toLowerCase() : i18n.CLOSED_CASE.toLowerCase() + } ${i18n.CASE}`; + } else if (field === 'comment' && action.action === 'update') { + return `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`; + } else if (field === 'pushed' && action.action === 'push-to-service' && action.newValue != null) { + return getPushedServiceLabelTitle(action, firstIndexPushToService, index); + } + return ''; +}; + +const getTagsLabelTitle = (action: CaseUserActions) => ( + + + {action.action === 'add' && i18n.ADDED_FIELD} + {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} + + {action.newValue != null && + action.newValue.split(',').map(tag => ( + + {tag} + + ))} + +); + +const getPushedServiceLabelTitle = ( + action: CaseUserActions, + firstIndexPushToService: number, + index: number +) => { + const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService; + return ( + + + {firstIndexPushToService === index ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} + + + + {pushedVal?.connector_name} {pushedVal?.external_title} + + + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index 6a3d319561353..8b77186f76f77 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -4,27 +4,54 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useMemo, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import styled from 'styled-components'; + import * as i18n from '../case_view/translations'; -import { Case } from '../../../../containers/case/types'; +import { Case, CaseUserActions, Comment } from '../../../../containers/case/types'; import { useUpdateComment } from '../../../../containers/case/use_update_comment'; +import { useCurrentUser } from '../../../../lib/kibana'; +import { AddComment } from '../add_comment'; +import { getLabelTitle } from './helpers'; import { UserActionItem } from './user_action_item'; import { UserActionMarkdown } from './user_action_markdown'; -import { AddComment } from '../add_comment'; -import { useCurrentUser } from '../../../../lib/kibana'; export interface UserActionTreeProps { data: Case; + caseUserActions: CaseUserActions[]; + fetchUserActions: () => void; + firstIndexPushToService: number; isLoadingDescription: boolean; + isLoadingUserActions: boolean; + lastIndexPushToService: number; onUpdateField: (updateKey: keyof Case, updateValue: string | string[]) => void; } -const DescriptionId = 'description'; -const NewId = 'newComment'; +const MyEuiFlexGroup = styled(EuiFlexGroup)` + margin-bottom: 8px; +`; + +const DESCRIPTION_ID = 'description'; +const NEW_ID = 'newComment'; export const UserActionTree = React.memo( - ({ data: caseData, onUpdateField, isLoadingDescription }: UserActionTreeProps) => { + ({ + data: caseData, + caseUserActions, + fetchUserActions, + firstIndexPushToService, + isLoadingDescription, + isLoadingUserActions, + lastIndexPushToService, + onUpdateField, + }: UserActionTreeProps) => { + const { commentId } = useParams(); + const handlerTimeoutId = useRef(0); + const [initLoading, setInitLoading] = useState(true); + const [selectedOutlineCommentId, setSelectedOutlineCommentId] = useState(''); const { comments, isLoadingIds, updateComment, addPostedComment } = useUpdateComment( caseData.comments ); @@ -45,20 +72,54 @@ export const UserActionTree = React.memo( const handleSaveComment = useCallback( (id: string, content: string) => { handleManageMarkdownEditId(id); - updateComment(caseData.id, id, content); + updateComment({ + caseId: caseData.id, + commentId: id, + commentUpdate: content, + fetchUserActions, + }); }, [handleManageMarkdownEditId, updateComment] ); + const handleOutlineComment = useCallback( + (id: string) => { + const moveToTarget = document.getElementById(`${id}-permLink`); + if (moveToTarget != null) { + const yOffset = -60; + const y = moveToTarget.getBoundingClientRect().top + window.pageYOffset + yOffset; + window.scrollTo({ + top: y, + behavior: 'smooth', + }); + } + window.clearTimeout(handlerTimeoutId.current); + setSelectedOutlineCommentId(id); + handlerTimeoutId.current = window.setTimeout(() => { + setSelectedOutlineCommentId(''); + window.clearTimeout(handlerTimeoutId.current); + }, 2400); + }, + [handlerTimeoutId.current] + ); + + const handleUpdate = useCallback( + (comment: Comment) => { + addPostedComment(comment); + fetchUserActions(); + }, + [addPostedComment, fetchUserActions] + ); + const MarkdownDescription = useMemo( () => ( { - handleManageMarkdownEditId(DescriptionId); - onUpdateField(DescriptionId, content); + handleManageMarkdownEditId(DESCRIPTION_ID); + onUpdateField(DESCRIPTION_ID, content); }} onChangeEditable={handleManageMarkdownEditId} /> @@ -67,55 +128,123 @@ export const UserActionTree = React.memo( ); const MarkdownNewComment = useMemo( - () => , - [caseData.id] + () => ( + + ), + [caseData.id, handleUpdate] ); + useEffect(() => { + if (initLoading && !isLoadingUserActions && isLoadingIds.length === 0) { + setInitLoading(false); + if (commentId != null) { + handleOutlineComment(commentId); + } + } + }, [commentId, initLoading, isLoadingUserActions, isLoadingIds]); + return ( <> {i18n.ADDED_DESCRIPTION}} fullName={caseData.createdBy.fullName ?? caseData.createdBy.username} markdown={MarkdownDescription} - onEdit={handleManageMarkdownEditId.bind(null, DescriptionId)} + onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)} userName={caseData.createdBy.username} /> - {comments.map(comment => ( - + + {caseUserActions.map((action, index) => { + if (action.commentId != null && action.action === 'create') { + const comment = comments.find(c => c.id === action.commentId); + if (comment != null) { + return ( + {i18n.ADDED_COMMENT}} + fullName={comment.createdBy.fullName ?? comment.createdBy.username} + markdown={ + + } + onEdit={handleManageMarkdownEditId.bind(null, comment.id)} + outlineComment={handleOutlineComment} + userName={comment.createdBy.username} + updatedAt={comment.updatedAt} + /> + ); } - onEdit={handleManageMarkdownEditId.bind(null, comment.id)} - userName={comment.createdBy.username} - /> - ))} + } + if (action.actionField.length === 1) { + const myField = action.actionField[0]; + const labelTitle: string | JSX.Element = getLabelTitle({ + action, + field: myField, + firstIndexPushToService, + index, + }); + + return ( + {labelTitle}} + linkId={ + action.action === 'update' && action.commentId != null ? action.commentId : null + } + fullName={action.actionBy.fullName ?? action.actionBy.username} + outlineComment={handleOutlineComment} + showTopFooter={ + action.action === 'push-to-service' && index === lastIndexPushToService + } + showBottomFooter={ + action.action === 'push-to-service' && + index === lastIndexPushToService && + index < caseUserActions.length - 1 + } + userName={action.actionBy.username} + /> + ); + } + return null; + })} + {(isLoadingUserActions || isLoadingIds.includes(NEW_ID)) && ( + + + + + + )} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts new file mode 100644 index 0000000000000..0ca6bcff513fc --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../case_view/translations'; + +export const ALREADY_PUSHED_TO_SERVICE = i18n.translate( + 'xpack.siem.case.caseView.alreadyPushedToService', + { + defaultMessage: 'Already pushed to Service Now incident', + } +); + +export const REQUIRED_UPDATE_TO_SERVICE = i18n.translate( + 'xpack.siem.case.caseView.requiredUpdateToService', + { + defaultMessage: 'Requires update to ServiceNow incident', + } +); + +export const COPY_LINK_COMMENT = i18n.translate('xpack.siem.case.caseView.copyCommentLinkAria', { + defaultMessage: 'click to copy comment link', +}); + +export const MOVE_TO_ORIGINAL_COMMENT = i18n.translate( + 'xpack.siem.case.caseView.moveToCommentAria', + { + defaultMessage: 'click to highlight the reference comment', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx index ca73f200f1793..10a7c56e2eb2d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -4,12 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPanel, + EuiHorizontalRule, + EuiText, +} from '@elastic/eui'; import React from 'react'; - import styled, { css } from 'styled-components'; + import { UserActionAvatar } from './user_action_avatar'; import { UserActionTitle } from './user_action_title'; +import * as i18n from './translations'; interface UserActionItemProps { createdAt: string; @@ -17,14 +25,20 @@ interface UserActionItemProps { isEditable: boolean; isLoading: boolean; labelEditAction?: string; - labelTitle?: string; + labelTitle?: JSX.Element; + linkId?: string | null; fullName: string; - markdown: React.ReactNode; - onEdit: (id: string) => void; + markdown?: React.ReactNode; + onEdit?: (id: string) => void; userName: string; + updatedAt?: string | null; + outlineComment?: (id: string) => void; + showBottomFooter?: boolean; + showTopFooter?: boolean; + idToOutline?: string | null; } -const UserActionItemContainer = styled(EuiFlexGroup)` +export const UserActionItemContainer = styled(EuiFlexGroup)` ${({ theme }) => css` & { background-image: linear-gradient( @@ -66,42 +80,102 @@ const UserActionItemContainer = styled(EuiFlexGroup)` `} `; +const MyEuiPanel = styled(EuiPanel)<{ showoutline: string }>` + ${({ theme, showoutline }) => + showoutline === 'true' + ? ` + outline: solid 5px ${theme.eui.euiColorVis1_behindText}; + margin: 0.5em; + transition: 0.8s; + ` + : ''} +`; + +const PushedContainer = styled(EuiFlexItem)` + ${({ theme }) => ` + margin-top: ${theme.eui.euiSizeS}; + margin-bottom: ${theme.eui.euiSizeXL}; + hr { + margin: 5px; + height: ${theme.eui.euiBorderWidthThick}; + } + `} +`; + +const PushedInfoContainer = styled.div` + margin-left: 48px; +`; + export const UserActionItem = ({ createdAt, id, + idToOutline, isEditable, isLoading, labelEditAction, labelTitle, + linkId, fullName, markdown, onEdit, + outlineComment, + showBottomFooter, + showTopFooter, userName, + updatedAt, }: UserActionItemProps) => ( - - - {fullName.length > 0 || userName.length > 0 ? ( - - ) : ( - - )} - - - {isEditable && markdown} - {!isEditable && ( - - - {markdown} - - )} + + + + + {fullName.length > 0 || userName.length > 0 ? ( + + ) : ( + + )} + + + {isEditable && markdown} + {!isEditable && ( + + } + linkId={linkId} + userName={userName} + updatedAt={updatedAt} + onEdit={onEdit} + outlineComment={outlineComment} + /> + {markdown} + + )} + + + {showTopFooter && ( + + + + {i18n.ALREADY_PUSHED_TO_SERVICE} + + + + {showBottomFooter && ( + + + {i18n.REQUIRED_UPDATE_TO_SERVICE} + + + )} + + )} ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx index 0ed081e8852f0..6ca81667d9712 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx @@ -4,16 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import React, { useMemo } from 'react'; +import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiText, EuiButtonIcon } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n/react'; +import copy from 'copy-to-clipboard'; +import { isEmpty } from 'lodash/fp'; +import React, { useMemo, useCallback } from 'react'; import styled from 'styled-components'; +import { useParams } from 'react-router-dom'; -import { - FormattedRelativePreferenceDate, - FormattedRelativePreferenceLabel, -} from '../../../../components/formatted_date'; -import * as i18n from '../case_view/translations'; +import { LocalizedDateTooltip } from '../../../../components/localized_date_tooltip'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; +import { navTabs } from '../../../home/home_navigations'; import { PropertyActions } from '../property_actions'; +import { SiemPageName } from '../../../home/types'; +import * as i18n from './translations'; const MySpinner = styled(EuiLoadingSpinner)` .euiLoadingSpinner { @@ -25,10 +29,13 @@ interface UserActionTitleProps { createdAt: string; id: string; isLoading: boolean; - labelEditAction: string; - labelTitle: string; + labelEditAction?: string; + labelTitle: JSX.Element; + linkId?: string | null; + updatedAt?: string | null; userName: string; - onEdit: (id: string) => void; + onEdit?: (id: string) => void; + outlineComment?: (id: string) => void; } export const UserActionTitle = ({ @@ -37,32 +44,107 @@ export const UserActionTitle = ({ isLoading, labelEditAction, labelTitle, + linkId, userName, + updatedAt, onEdit, + outlineComment, }: UserActionTitleProps) => { + const { detailName: caseId } = useParams(); + const urlSearch = useGetUrlSearch(navTabs.case); const propertyActions = useMemo(() => { - return [ + if (labelEditAction != null && onEdit != null) { + return [ + { + iconType: 'pencil', + label: labelEditAction, + onClick: () => onEdit(id), + }, + ]; + } + return []; + }, [id, labelEditAction, onEdit]); + + const handleAnchorLink = useCallback(() => { + copy( + `${window.location.origin}${window.location.pathname}#${SiemPageName.case}/${caseId}/${id}${urlSearch}`, { - iconType: 'pencil', - label: labelEditAction, - onClick: () => onEdit(id), - }, - ]; - }, [id, onEdit]); + debug: true, + } + ); + }, [caseId, id, urlSearch]); + + const handleMoveToLink = useCallback(() => { + if (outlineComment != null && linkId != null) { + outlineComment(linkId); + } + }, [linkId, outlineComment]); + return ( - + -

- {userName} - {` ${labelTitle} `} - - -

+ + + {userName} + + {labelTitle} + + + + + + {updatedAt != null && ( + + + {'('} + {i18n.EDITED_FIELD}{' '} + + + + {')'} + + + )} +
- {isLoading && } - {!isLoading && } + + {!isEmpty(linkId) && ( + + + + )} + + + + {propertyActions.length > 0 && ( + + {isLoading && } + {!isLoading && } + + )} +
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/index.tsx index 1bde9de1535b5..124cefa726a8b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/index.tsx @@ -15,6 +15,7 @@ import { ConfigureCasesPage } from './configure_cases'; const casesPagePath = `/:pageName(${SiemPageName.case})`; const caseDetailsPagePath = `${casesPagePath}/:detailName`; +const caseDetailsPagePathWithCommentId = `${casesPagePath}/:detailName/:commentId`; const createCasePagePath = `${casesPagePath}/create`; const configureCasesPagePath = `${casesPagePath}/configure`; @@ -29,6 +30,9 @@ const CaseContainerComponent: React.FC = () => ( + + + diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index 341a34240fe49..8f9d2087699f8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -33,17 +33,15 @@ export const OPENED_ON = i18n.translate('xpack.siem.case.caseView.openedOn', { export const CLOSED_ON = i18n.translate('xpack.siem.case.caseView.closedOn', { defaultMessage: 'Closed on', }); -export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', { - defaultMessage: 'Reopen case', -}); -export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', { - defaultMessage: 'Close case', -}); -export const REPORTER = i18n.translate('xpack.siem.case.caseView.createdBy', { +export const REPORTER = i18n.translate('xpack.siem.case.caseView.reporterLabel', { defaultMessage: 'Reporter', }); +export const PARTICIPANTS = i18n.translate('xpack.siem.case.caseView.particpantsLabel', { + defaultMessage: 'Participants', +}); + export const CREATE_BC_TITLE = i18n.translate('xpack.siem.case.caseView.breadcrumb', { defaultMessage: 'Create', }); @@ -90,6 +88,30 @@ export const CREATE_CASE = i18n.translate('xpack.siem.case.caseView.createCase', defaultMessage: 'Create case', }); +export const CLOSED_CASE = i18n.translate('xpack.siem.case.caseView.closedCase', { + defaultMessage: 'Closed case', +}); + +export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseView.closeCase', { + defaultMessage: 'Close case', +}); + +export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseView.reopenCase', { + defaultMessage: 'Reopen case', +}); + +export const REOPENED_CASE = i18n.translate('xpack.siem.case.caseView.reopenedCase', { + defaultMessage: 'Reopened case', +}); + +export const CASE_NAME = i18n.translate('xpack.siem.case.caseView.caseName', { + defaultMessage: 'Case name', +}); + +export const TO = i18n.translate('xpack.siem.case.caseView.to', { + defaultMessage: 'to', +}); + export const TAGS = i18n.translate('xpack.siem.case.caseView.tags', { defaultMessage: 'Tags', }); @@ -130,7 +152,7 @@ export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate( ); export const CONFIGURE_CASES_BUTTON = i18n.translate('xpack.siem.case.configureCasesButton', { - defaultMessage: 'Edit third-party connection', + defaultMessage: 'Edit external connection', }); export const ADD_COMMENT = i18n.translate('xpack.siem.case.caseView.comment.addComment', { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts index ccb3b71a476ec..3f2964b8cdd6d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts @@ -21,7 +21,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { ...breadcrumb, { text: i18n.CREATE_BC_TITLE, - href: getCreateCaseUrl(), + href: getCreateCaseUrl(''), }, ]; } else if (params.detailName != null) { @@ -29,7 +29,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { ...breadcrumb, { text: params.state?.caseTitle ?? '', - href: getCaseDetailsUrl(params.detailName), + href: getCaseDetailsUrl(params.detailName, ''), }, ]; } diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 6f58e2702ec5b..ee244dd205113 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -11,6 +11,8 @@ import { UserRT } from '../user'; import { CommentResponseRt } from './comment'; import { CasesStatusResponseRt } from './status'; +export { ActionTypeExecutorResult } from '../../../../actions/server/types'; + const StatusRt = rt.union([rt.literal('open'), rt.literal('closed')]); const CaseBasicRt = rt.type({ @@ -20,14 +22,33 @@ const CaseBasicRt = rt.type({ title: rt.string, }); +const CaseExternalServiceBasicRt = rt.type({ + connector_id: rt.string, + connector_name: rt.string, + external_id: rt.string, + external_title: rt.string, + external_url: rt.string, +}); + +const CaseFullExternalServiceRt = rt.union([ + rt.intersection([ + CaseExternalServiceBasicRt, + rt.type({ + pushed_at: rt.string, + pushed_by: UserRT, + }), + ]), + rt.null, +]); + export const CaseAttributesRt = rt.intersection([ CaseBasicRt, rt.type({ - comment_ids: rt.array(rt.string), closed_at: rt.union([rt.string, rt.null]), closed_by: rt.union([UserRT, rt.null]), created_at: rt.string, created_by: UserRT, + external_service: CaseFullExternalServiceRt, updated_at: rt.union([rt.string, rt.null]), updated_by: rt.union([UserRT, rt.null]), }), @@ -35,6 +56,8 @@ export const CaseAttributesRt = rt.intersection([ export const CaseRequestRt = CaseBasicRt; +export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt; + export const CasesFindRequestRt = rt.partial({ tags: rt.union([rt.array(rt.string), rt.string]), status: StatusRt, @@ -53,6 +76,7 @@ export const CaseResponseRt = rt.intersection([ CaseAttributesRt, rt.type({ id: rt.string, + totalComment: rt.number, version: rt.string, }), rt.partial({ @@ -78,6 +102,60 @@ export const CasePatchRequestRt = rt.intersection([ export const CasesPatchRequestRt = rt.type({ cases: rt.array(CasePatchRequestRt) }); export const CasesResponseRt = rt.array(CaseResponseRt); +/* + * This type are related to this file below + * x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts + * why because this schema is not share in a common folder + * so we redefine then so we can use/validate types + */ + +const ServiceConnectorUserParams = rt.type({ + fullName: rt.union([rt.string, rt.null]), + username: rt.string, +}); + +export const ServiceConnectorCommentParamsRt = rt.type({ + commentId: rt.string, + comment: rt.string, + createdAt: rt.string, + createdBy: ServiceConnectorUserParams, + updatedAt: rt.union([rt.string, rt.null]), + updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), +}); + +export const ServiceConnectorCaseParamsRt = rt.intersection([ + rt.type({ + caseId: rt.string, + createdAt: rt.string, + createdBy: ServiceConnectorUserParams, + incidentId: rt.union([rt.string, rt.null]), + title: rt.string, + updatedAt: rt.union([rt.string, rt.null]), + updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), + }), + rt.partial({ + description: rt.string, + comments: rt.array(ServiceConnectorCommentParamsRt), + }), +]); + +export const ServiceConnectorCaseResponseRt = rt.intersection([ + rt.type({ + number: rt.string, + incidentId: rt.string, + pushedDate: rt.string, + url: rt.string, + }), + rt.partial({ + comments: rt.array( + rt.type({ + commentId: rt.string, + pushedDate: rt.string, + }) + ), + }), +]); + export type CaseAttributes = rt.TypeOf; export type CaseRequest = rt.TypeOf; export type CaseResponse = rt.TypeOf; @@ -85,3 +163,8 @@ export type CasesResponse = rt.TypeOf; export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; export type CasesPatchRequest = rt.TypeOf; +export type CaseExternalServiceRequest = rt.TypeOf; +export type ServiceConnectorCaseParams = rt.TypeOf; +export type ServiceConnectorCaseResponse = rt.TypeOf; +export type CaseFullExternalService = rt.TypeOf; +export type ServiceConnectorCommentParams = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index cebfa00425728..4549b1c31a7cf 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -17,6 +17,8 @@ export const CommentAttributesRt = rt.intersection([ rt.type({ created_at: rt.string, created_by: UserRT, + pushed_at: rt.union([rt.string, rt.null]), + pushed_by: rt.union([UserRT, rt.null]), updated_at: rt.union([rt.string, rt.null]), updated_by: rt.union([UserRT, rt.null]), }), diff --git a/x-pack/plugins/case/common/api/cases/configure.ts b/x-pack/plugins/case/common/api/cases/configure.ts index e0489ed7270fa..9b210c2aa05ad 100644 --- a/x-pack/plugins/case/common/api/cases/configure.ts +++ b/x-pack/plugins/case/common/api/cases/configure.ts @@ -73,6 +73,7 @@ const ClosureTypeRT = rt.union([rt.literal('close-by-user'), rt.literal('close-b const CasesConfigureBasicRt = rt.type({ connector_id: rt.string, + connector_name: rt.string, closure_type: ClosureTypeRT, }); diff --git a/x-pack/plugins/case/common/api/cases/index.ts b/x-pack/plugins/case/common/api/cases/index.ts index 5fbee98bc57ad..ffcd4d25eecf5 100644 --- a/x-pack/plugins/case/common/api/cases/index.ts +++ b/x-pack/plugins/case/common/api/cases/index.ts @@ -8,3 +8,4 @@ export * from './case'; export * from './configure'; export * from './comment'; export * from './status'; +export * from './user_actions'; diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts new file mode 100644 index 0000000000000..2b70a698a5152 --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -0,0 +1,59 @@ +/* + * 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 * as rt from 'io-ts'; + +import { UserRT } from '../user'; + +/* To the next developer, if you add/removed fields here + * make sure to check this file (x-pack/plugins/case/server/services/user_actions/helpers.ts) too + */ +const UserActionFieldRt = rt.array( + rt.union([ + rt.literal('comment'), + rt.literal('description'), + rt.literal('pushed'), + rt.literal('tags'), + rt.literal('title'), + rt.literal('status'), + ]) +); +const UserActionRt = rt.union([ + rt.literal('add'), + rt.literal('create'), + rt.literal('delete'), + rt.literal('update'), + rt.literal('push-to-service'), +]); + +// TO DO change state to status +const CaseUserActionBasicRT = rt.type({ + action_field: UserActionFieldRt, + action: UserActionRt, + action_at: rt.string, + action_by: UserRT, + new_value: rt.union([rt.string, rt.null]), + old_value: rt.union([rt.string, rt.null]), +}); + +const CaseUserActionResponseRT = rt.intersection([ + CaseUserActionBasicRT, + rt.type({ + action_id: rt.string, + case_id: rt.string, + comment_id: rt.union([rt.string, rt.null]), + }), +]); + +export const CaseUserActionAttributesRt = CaseUserActionBasicRT; + +export const CaseUserActionsResponseRt = rt.array(CaseUserActionResponseRT); + +export type CaseUserActionAttributes = rt.TypeOf; +export type CaseUserActionsResponse = rt.TypeOf; + +export type UserAction = rt.TypeOf; +export type UserActionField = rt.TypeOf; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 1d6495c2d81f3..a6a459373b0ed 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -16,8 +16,9 @@ import { caseSavedObjectType, caseConfigureSavedObjectType, caseCommentSavedObjectType, + caseUserActionSavedObjectType, } from './saved_object_types'; -import { CaseConfigureService, CaseService } from './services'; +import { CaseConfigureService, CaseService, CaseUserActionService } from './services'; function createConfig$(context: PluginInitializerContext) { return context.config.create().pipe(map(config => config)); @@ -46,9 +47,11 @@ export class CasePlugin { core.savedObjects.registerType(caseSavedObjectType); core.savedObjects.registerType(caseCommentSavedObjectType); core.savedObjects.registerType(caseConfigureSavedObjectType); + core.savedObjects.registerType(caseUserActionSavedObjectType); const caseServicePlugin = new CaseService(this.log); const caseConfigureServicePlugin = new CaseConfigureService(this.log); + const userActionServicePlugin = new CaseUserActionService(this.log); this.log.debug( `Setting up Case Workflow with core contract [${Object.keys( @@ -60,11 +63,13 @@ export class CasePlugin { authentication: plugins.security.authc, }); const caseConfigureService = await caseConfigureServicePlugin.setup(); + const userActionService = await userActionServicePlugin.setup(); const router = core.http.createRouter(); initCaseApi({ caseConfigureService, caseService, + userActionService, router, }); } diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts index bc41ddbeff1f9..eff91fff32c02 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -32,6 +32,10 @@ export const createRoute = async ( caseConfigureService, caseService, router, + userActionService: { + postUserActions: jest.fn(), + getUserActions: jest.fn(), + }, }); return router[method].mock.calls[0][1]; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 5aa8b93f17b08..03da50f886fd5 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -14,7 +14,6 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, - comment_ids: ['mock-comment-1'], created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -22,6 +21,7 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'This is a brand new case of a bad meanie defacing data', + external_service: null, title: 'Super Bad Security Issue', status: 'open', tags: ['defacement'], @@ -42,7 +42,6 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, - comment_ids: [], created_at: '2019-11-25T22:32:00.900Z', created_by: { full_name: 'elastic', @@ -50,6 +49,7 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'Oh no, a bad meanie destroying data!', + external_service: null, title: 'Damaging Data Destruction Detected', status: 'open', tags: ['Data Destruction'], @@ -70,7 +70,6 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, - comment_ids: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -78,6 +77,7 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'Oh no, a bad meanie going LOLBins all over the place!', + external_service: null, title: 'Another bad one', status: 'open', tags: ['LOLBins'], @@ -102,7 +102,6 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, - comment_ids: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -110,8 +109,9 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'Oh no, a bad meanie going LOLBins all over the place!', - title: 'Another bad one', + external_service: null, status: 'closed', + title: 'Another bad one', tags: ['LOLBins'], updated_at: '2019-11-25T22:32:17.947Z', updated_by: { @@ -147,6 +147,8 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + pushed_at: null, + pushed_by: null, updated_at: '2019-11-25T21:55:00.177Z', updated_by: { full_name: 'elastic', @@ -175,6 +177,8 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + pushed_at: null, + pushed_by: null, updated_at: '2019-11-25T21:55:14.633Z', updated_by: { full_name: 'elastic', @@ -204,6 +208,8 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + pushed_at: null, + pushed_by: null, updated_at: '2019-11-25T22:32:30.608Z', updated_by: { full_name: 'elastic', diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts index 00d06bfdd2677..941ac90f2e90e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts @@ -5,10 +5,12 @@ */ import { schema } from '@kbn/config-schema'; + +import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -export function initDeleteAllCommentsApi({ caseService, router }: RouteDeps) { +export function initDeleteAllCommentsApi({ caseService, router, userActionService }: RouteDeps) { router.delete( { path: '/api/cases/{case_id}/comments', @@ -21,9 +23,11 @@ export function initDeleteAllCommentsApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; + const { username, full_name, email } = await caseService.getUser({ request, response }); + const deleteDate = new Date().toISOString(); const comments = await caseService.getAllCaseComments({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); await Promise.all( @@ -35,15 +39,18 @@ export function initDeleteAllCommentsApi({ caseService, router }: RouteDeps) { ) ); - const updateCase = { - comment_ids: [], - }; - await caseService.patchCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, - updatedAttributes: { - ...updateCase, - }, + await userActionService.postUserActions({ + client, + actions: comments.saved_objects.map(comment => + buildCommentUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: { username, full_name, email }, + caseId: request.params.case_id, + commentId: comment.id, + fields: ['comment'], + }) + ), }); return response.ok({ body: 'true' }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts index 85c4701f82e1d..44e57fc809e04 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts @@ -6,10 +6,13 @@ import Boom from 'boom'; import { schema } from '@kbn/config-schema'; + +import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -export function initDeleteCommentApi({ caseService, router }: RouteDeps) { +export function initDeleteCommentApi({ caseService, router, userActionService }: RouteDeps) { router.delete( { path: '/api/cases/{case_id}/comments/{comment_id}', @@ -23,14 +26,22 @@ export function initDeleteCommentApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; - const myCase = await caseService.getCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, + const { username, full_name, email } = await caseService.getUser({ request, response }); + const deleteDate = new Date().toISOString(); + + const myComment = await caseService.getComment({ + client, + commentId: request.params.comment_id, }); - if (!myCase.attributes.comment_ids.includes(request.params.comment_id)) { + if (myComment == null) { + throw Boom.notFound(`This comment ${request.params.comment_id} does not exist anymore.`); + } + + const caseRef = myComment.references.find(c => c.type === CASE_SAVED_OBJECT); + if (caseRef == null || (caseRef != null && caseRef.id !== request.params.case_id)) { throw Boom.notFound( - `This comment ${request.params.comment_id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` + `This comment ${request.params.comment_id} does not exist in ${request.params.case_id}).` ); } @@ -39,17 +50,18 @@ export function initDeleteCommentApi({ caseService, router }: RouteDeps) { commentId: request.params.comment_id, }); - const updateCase = { - comment_ids: myCase.attributes.comment_ids.filter( - cId => cId !== request.params.comment_id - ), - }; - await caseService.patchCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, - updatedAttributes: { - ...updateCase, - }, + await userActionService.postUserActions({ + client, + actions: [ + buildCommentUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: { username, full_name, email }, + caseId: request.params.case_id, + commentId: request.params.comment_id, + fields: ['comment'], + }), + ], }); return response.ok({ body: 'true' }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts index dcf70d0d9819c..92da64cebee74 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts @@ -32,6 +32,7 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( SavedObjectFindOptionsRt.decode(request.query), fold(throwErrors(Boom.badRequest), identity) @@ -39,7 +40,7 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { const args = query ? { - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, options: { ...query, @@ -47,7 +48,7 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { }, } : { - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }; diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts index 65f2de7125236..1500039eb2cc2 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts @@ -22,8 +22,9 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const comments = await caseService.getAllCaseComments({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); return response.ok({ diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts index 06619abae8487..24f44a5f5129b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts @@ -5,7 +5,6 @@ */ import { schema } from '@kbn/config-schema'; -import Boom from 'boom'; import { CommentResponseRt } from '../../../../../common/api'; import { RouteDeps } from '../../types'; @@ -25,16 +24,6 @@ export function initGetCommentApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; - const myCase = await caseService.getCase({ - client, - caseId: request.params.case_id, - }); - - if (!myCase.attributes.comment_ids.includes(request.params.comment_id)) { - throw Boom.notFound( - `This comment ${request.params.comment_id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` - ); - } const comment = await caseService.getComment({ client, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index c14a94e84e51c..c67ad1bdaea71 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -11,11 +11,12 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { CommentPatchRequestRt, CommentResponseRt, throwErrors } from '../../../../../common/api'; - +import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { escapeHatch, wrapError, flattenCommentSavedObject } from '../../utils'; -export function initPatchCommentApi({ caseService, router }: RouteDeps) { +export function initPatchCommentApi({ caseService, router, userActionService }: RouteDeps) { router.patch( { path: '/api/cases/{case_id}/comments', @@ -28,46 +29,63 @@ export function initPatchCommentApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( CommentPatchRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const myCase = await caseService.getCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, + const myComment = await caseService.getComment({ + client, + commentId: query.id, }); - if (!myCase.attributes.comment_ids.includes(query.id)) { + if (myComment == null) { + throw Boom.notFound(`This comment ${query.id} does not exist anymore.`); + } + + const caseRef = myComment.references.find(c => c.type === CASE_SAVED_OBJECT); + if (caseRef == null || (caseRef != null && caseRef.id !== request.params.case_id)) { throw Boom.notFound( - `This comment ${query.id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` + `This comment ${query.id} does not exist in ${request.params.case_id}).` ); } - const myComment = await caseService.getComment({ - client: context.core.savedObjects.client, - commentId: query.id, - }); - if (query.version !== myComment.version) { throw Boom.conflict( 'This case has been updated. Please refresh before saving additional updates.' ); } - const updatedBy = await caseService.getUser({ request, response }); - const { email, full_name, username } = updatedBy; + const { username, full_name, email } = await caseService.getUser({ request, response }); + const updatedDate = new Date().toISOString(); const updatedComment = await caseService.patchComment({ - client: context.core.savedObjects.client, + client, commentId: query.id, updatedAttributes: { comment: query.comment, - updated_at: new Date().toISOString(), + updated_at: updatedDate, updated_by: { email, full_name, username }, }, version: query.version, }); + await userActionService.postUserActions({ + client, + actions: [ + buildCommentUserActionItem({ + action: 'update', + actionAt: updatedDate, + actionBy: { username, full_name, email }, + caseId: request.params.case_id, + commentId: updatedComment.id, + fields: ['comment'], + newValue: query.comment, + oldValue: myComment.attributes.comment, + }), + ], + }); + return response.ok({ body: CommentResponseRt.encode( flattenCommentSavedObject({ diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index 9e82a8ffaaec7..2410505872a3a 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -12,6 +12,7 @@ import { identity } from 'fp-ts/lib/function'; import { CommentRequestRt, CommentResponseRt, throwErrors } from '../../../../../common/api'; import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { escapeHatch, transformNewComment, @@ -20,7 +21,7 @@ import { } from '../../utils'; import { RouteDeps } from '../../types'; -export function initPostCommentApi({ caseService, router }: RouteDeps) { +export function initPostCommentApi({ caseService, router, userActionService }: RouteDeps) { router.post( { path: '/api/cases/{case_id}/comments', @@ -33,25 +34,28 @@ export function initPostCommentApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( CommentRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); const myCase = await caseService.getCase({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); - const createdBy = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); const newComment = await caseService.postNewComment({ - client: context.core.savedObjects.client, + client, attributes: transformNewComment({ createdDate, ...query, - ...createdBy, + username, + full_name, + email, }), references: [ { @@ -62,16 +66,19 @@ export function initPostCommentApi({ caseService, router }: RouteDeps) { ], }); - const updateCase = { - comment_ids: [...myCase.attributes.comment_ids, newComment.id], - }; - - await caseService.patchCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, - updatedAttributes: { - ...updateCase, - }, + await userActionService.postUserActions({ + client, + actions: [ + buildCommentUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { username, full_name, email }, + caseId: myCase.id, + commentId: newComment.id, + fields: ['comment'], + newValue: query.comment, + }), + ], }); return response.ok({ diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts index 1542394fc438d..3a1b9d5059cbc 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts @@ -48,8 +48,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout ); } - const updatedBy = await caseService.getUser({ request, response }); - const { email, full_name, username } = updatedBy; + const { username, full_name, email } = await caseService.getUser({ request, response }); const updateDate = new Date().toISOString(); const patch = await caseConfigureService.patch({ diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts index c839d36dcf4df..2a23abf0cbf21 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts @@ -42,8 +42,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route ) ); } - const updatedBy = await caseService.getUser({ request, response }); - const { email, full_name, username } = updatedBy; + const { email, full_name, username } = await caseService.getUser({ request, response }); const creationDate = new Date().toISOString(); const post = await caseConfigureService.post({ diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts index 559a477a83a6c..8b0384c12edce 100644 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts @@ -5,10 +5,12 @@ */ import { schema } from '@kbn/config-schema'; + +import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { wrapError } from '../utils'; -export function initDeleteCasesApi({ caseService, router }: RouteDeps) { +export function initDeleteCasesApi({ caseService, router, userActionService }: RouteDeps) { router.delete( { path: '/api/cases', @@ -20,10 +22,11 @@ export function initDeleteCasesApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; await Promise.all( request.query.ids.map(id => caseService.deleteCase({ - client: context.core.savedObjects.client, + client, caseId: id, }) ) @@ -31,7 +34,7 @@ export function initDeleteCasesApi({ caseService, router }: RouteDeps) { const comments = await Promise.all( request.query.ids.map(id => caseService.getAllCaseComments({ - client: context.core.savedObjects.client, + client, caseId: id, }) ) @@ -43,7 +46,7 @@ export function initDeleteCasesApi({ caseService, router }: RouteDeps) { Promise.all( c.saved_objects.map(({ id }) => caseService.deleteComment({ - client: context.core.savedObjects.client, + client, commentId: id, }) ) @@ -51,6 +54,22 @@ export function initDeleteCasesApi({ caseService, router }: RouteDeps) { ) ); } + const { username, full_name, email } = await caseService.getUser({ request, response }); + const deleteDate = new Date().toISOString(); + + await userActionService.postUserActions({ + client, + actions: request.query.ids.map(id => + buildCaseUserActionItem({ + action: 'create', + actionAt: deleteDate, + actionBy: { username, full_name, email }, + caseId: id, + fields: ['comment', 'description', 'status', 'tags', 'title'], + }) + ), + }); + return response.ok({ body: 'true' }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts index 76a1992c64270..e7b2044f2badf 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts @@ -13,7 +13,7 @@ import { identity } from 'fp-ts/lib/function'; import { isEmpty } from 'lodash'; import { CasesFindResponseRt, CasesFindRequestRt, throwErrors } from '../../../../common/api'; import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; -import { RouteDeps } from '../types'; +import { RouteDeps, TotalCommentByCase } from '../types'; import { CASE_SAVED_OBJECT } from '../../../saved_object_types'; const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string => @@ -97,9 +97,44 @@ export function initFindCasesApi({ caseService, router }: RouteDeps) { caseService.findCases(argsOpenCases), caseService.findCases(argsClosedCases), ]); + + const totalCommentsFindByCases = await Promise.all( + cases.saved_objects.map(c => + caseService.getAllCaseComments({ + client, + caseId: c.id, + options: { + fields: [], + page: 1, + perPage: 1, + }, + }) + ) + ); + + const totalCommentsByCases = totalCommentsFindByCases.reduce( + (acc, itemFind) => { + if (itemFind.saved_objects.length > 0) { + const caseId = + itemFind.saved_objects[0].references.find(r => r.type === CASE_SAVED_OBJECT)?.id ?? + null; + if (caseId != null) { + return [...acc, { caseId, totalComments: itemFind.total }]; + } + } + return [...acc]; + }, + [] + ); + return response.ok({ body: CasesFindResponseRt.encode( - transformCases(cases, openCases.total ?? 0, closesCases.total ?? 0) + transformCases( + cases, + openCases.total ?? 0, + closesCases.total ?? 0, + totalCommentsByCases + ) ), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.ts index 1415513bca346..e947118a39e8e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.ts @@ -25,10 +25,11 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const includeComments = JSON.parse(request.query.includeComments); const theCase = await caseService.getCase({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); @@ -37,7 +38,7 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { } const theComments = await caseService.getAllCaseComments({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index 3bf46cadc83c8..747b5195da7ec 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -4,10 +4,57 @@ * you may not use this file except in compliance with the Elastic License. */ -import { difference, get } from 'lodash'; +import { get } from 'lodash'; import { CaseAttributes, CasePatchRequest } from '../../../../common/api'; +interface CompareArrays { + addedItems: string[]; + deletedItems: string[]; +} +export const compareArrays = ({ + originalValue, + updatedValue, +}: { + originalValue: string[]; + updatedValue: string[]; +}): CompareArrays => { + const result: CompareArrays = { + addedItems: [], + deletedItems: [], + }; + originalValue.forEach(origVal => { + if (!updatedValue.includes(origVal)) { + result.deletedItems = [...result.deletedItems, origVal]; + } + }); + updatedValue.forEach(updatedVal => { + if (!originalValue.includes(updatedVal)) { + result.addedItems = [...result.addedItems, updatedVal]; + } + }); + + return result; +}; + +export const isTwoArraysDifference = ( + originalValue: unknown, + updatedValue: unknown +): CompareArrays | null => { + if ( + originalValue != null && + updatedValue != null && + Array.isArray(updatedValue) && + Array.isArray(originalValue) + ) { + const compObj = compareArrays({ originalValue, updatedValue }); + if (compObj.addedItems.length > 0 || compObj.deletedItems.length > 0) { + return compObj; + } + } + return null; +}; + export const getCaseToUpdate = ( currentCase: CaseAttributes, queryCase: CasePatchRequest @@ -15,12 +62,7 @@ export const getCaseToUpdate = ( Object.entries(queryCase).reduce( (acc, [key, value]) => { const currentValue = get(currentCase, key); - if ( - currentValue != null && - Array.isArray(value) && - Array.isArray(currentValue) && - difference(value, currentValue).length !== 0 - ) { + if (isTwoArraysDifference(value, currentValue)) { return { ...acc, [key]: value, diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 19ff7f0734a77..ac1e67cec52bd 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -52,15 +52,16 @@ describe('PATCH cases', () => { { closed_at: '2019-11-25T21:54:48.952Z', closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - comment_ids: ['mock-comment-1'], comments: [], created_at: '2019-11-25T21:54:48.952Z', created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'This is a brand new case of a bad meanie defacing data', id: 'mock-id-1', + external_service: null, status: 'closed', tags: ['defacement'], title: 'Super Bad Security Issue', + totalComment: 0, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', @@ -94,15 +95,16 @@ describe('PATCH cases', () => { { closed_at: null, closed_by: null, - comment_ids: [], comments: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'Oh no, a bad meanie going LOLBins all over the place!', id: 'mock-id-4', + external_service: null, status: 'open', tags: ['LOLBins'], title: 'Another bad one', + totalComment: 0, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 4aa0d8daf5b34..3d0b7bc79f88b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -18,8 +18,9 @@ import { import { escapeHatch, wrapError, flattenCaseSavedObject } from '../utils'; import { RouteDeps } from '../types'; import { getCaseToUpdate } from './helpers'; +import { buildCaseUserActions } from '../../../services/user_actions/helpers'; -export function initPatchCasesApi({ caseService, router }: RouteDeps) { +export function initPatchCasesApi({ caseService, router, userActionService }: RouteDeps) { router.patch( { path: '/api/cases', @@ -29,12 +30,13 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( CasesPatchRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); const myCases = await caseService.getCases({ - client: context.core.savedObjects.client, + client, caseIds: query.cases.map(q => q.id), }); let nonExistingCases: CasePatchRequest[] = []; @@ -72,11 +74,10 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { return Object.keys(updateCaseAttributes).length > 0; }); if (updateFilterCases.length > 0) { - const updatedBy = await caseService.getUser({ request, response }); - const { email, full_name, username } = updatedBy; + const { username, full_name, email } = await caseService.getUser({ request, response }); const updatedDt = new Date().toISOString(); const updatedCases = await caseService.patchCases({ - client: context.core.savedObjects.client, + client, cases: updateFilterCases.map(thisCase => { const { id: caseId, version, ...updateCaseAttributes } = thisCase; let closedInfo = {}; @@ -103,6 +104,7 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { }; }), }); + const returnUpdatedCase = myCases.saved_objects .filter(myCase => updatedCases.saved_objects.some(updatedCase => updatedCase.id === myCase.id) @@ -116,6 +118,17 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { references: myCase.references, }); }); + + await userActionService.postUserActions({ + client, + actions: buildCaseUserActions({ + originalCases: myCases.saved_objects, + updatedCases: updatedCases.saved_objects, + actionDate: updatedDt, + actionBy: { email, full_name, username }, + }), + }); + return response.ok({ body: CasesResponseRt.encode(returnUpdatedCase), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.ts index 9e854c3178e1e..75be68013bcd4 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.ts @@ -12,9 +12,10 @@ import { identity } from 'fp-ts/lib/function'; import { flattenCaseSavedObject, transformNewCase, wrapError, escapeHatch } from '../utils'; import { CaseRequestRt, throwErrors, CaseResponseRt } from '../../../../common/api'; +import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; -export function initPostCaseApi({ caseService, router }: RouteDeps) { +export function initPostCaseApi({ caseService, router, userActionService }: RouteDeps) { router.post( { path: '/api/cases', @@ -24,21 +25,39 @@ export function initPostCaseApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( CaseRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const createdBy = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); const newCase = await caseService.postNewCase({ - client: context.core.savedObjects.client, + client, attributes: transformNewCase({ createdDate, newCase: query, - ...createdBy, + username, + full_name, + email, }), }); + + await userActionService.postUserActions({ + client, + actions: [ + buildCaseUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { username, full_name, email }, + caseId: newCase.id, + fields: ['description', 'status', 'tags', 'title'], + newValue: JSON.stringify(query), + }), + ], + }); + return response.ok({ body: CaseResponseRt.encode(flattenCaseSavedObject(newCase, [])) }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts new file mode 100644 index 0000000000000..6ae3df180d9e4 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import Boom from 'boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { flattenCaseSavedObject, wrapError, escapeHatch } from '../utils'; + +import { CaseExternalServiceRequestRt, CaseResponseRt, throwErrors } from '../../../../common/api'; +import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; +import { RouteDeps } from '../types'; + +export function initPushCaseUserActionApi({ + caseConfigureService, + caseService, + router, + userActionService, +}: RouteDeps) { + router.post( + { + path: '/api/cases/{case_id}/_push', + validate: { + params: schema.object({ + case_id: schema.string(), + }), + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const caseId = request.params.case_id; + const query = pipe( + CaseExternalServiceRequestRt.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + const { username, full_name, email } = await caseService.getUser({ request, response }); + const pushedDate = new Date().toISOString(); + + const [myCase, myCaseConfigure, totalCommentsFindByCases] = await Promise.all([ + caseService.getCase({ + client, + caseId: request.params.case_id, + }), + caseConfigureService.find({ client }), + caseService.getAllCaseComments({ + client, + caseId, + options: { + fields: [], + page: 1, + perPage: 1, + }, + }), + ]); + + if (myCase.attributes.status === 'closed') { + throw Boom.conflict( + `This case ${myCase.attributes.title} is closed. You can not pushed if the case is closed.` + ); + } + + const comments = await caseService.getAllCaseComments({ + client, + caseId, + options: { + fields: [], + page: 1, + perPage: totalCommentsFindByCases.total, + }, + }); + + const externalService = { + pushed_at: pushedDate, + pushed_by: { username, full_name, email }, + ...query, + }; + + const [updatedCase, updatedComments] = await Promise.all([ + caseService.patchCase({ + client, + caseId, + updatedAttributes: { + ...(myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' + ? { + status: 'closed', + closed_at: pushedDate, + closed_by: { email, full_name, username }, + } + : {}), + external_service: externalService, + updated_at: pushedDate, + updated_by: { username, full_name, email }, + }, + version: myCase.version, + }), + caseService.patchComments({ + client, + comments: comments.saved_objects.map(comment => ({ + commentId: comment.id, + updatedAttributes: { + pushed_at: pushedDate, + pushed_by: { username, full_name, email }, + updated_at: pushedDate, + updated_by: { username, full_name, email }, + }, + version: comment.version, + })), + }), + userActionService.postUserActions({ + client, + actions: [ + ...(myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' + ? [ + buildCaseUserActionItem({ + action: 'update', + actionAt: pushedDate, + actionBy: { username, full_name, email }, + caseId, + fields: ['status'], + newValue: 'closed', + oldValue: myCase.attributes.status, + }), + ] + : []), + buildCaseUserActionItem({ + action: 'push-to-service', + actionAt: pushedDate, + actionBy: { username, full_name, email }, + caseId, + fields: ['pushed'], + newValue: JSON.stringify(externalService), + }), + ], + }), + ]); + + return response.ok({ + body: CaseResponseRt.encode( + flattenCaseSavedObject( + { + ...myCase, + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase?.attributes }, + references: myCase.references, + }, + comments.saved_objects.map(origComment => { + const updatedComment = updatedComments.saved_objects.find( + c => c.id === origComment.id + ); + return { + ...origComment, + ...updatedComment, + attributes: { + ...origComment.attributes, + ...updatedComment?.attributes, + }, + version: updatedComment?.version ?? origComment.version, + references: origComment?.references ?? [], + }; + }) + ) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts b/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts index 519bb198f5f9e..56862a96e0563 100644 --- a/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts +++ b/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts @@ -16,8 +16,9 @@ export function initGetReportersApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const reporters = await caseService.getReporters({ - client: context.core.savedObjects.client, + client, }); return response.ok({ body: UsersRt.encode(reporters) }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts index b4fc90d702604..f7431729d398c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts @@ -18,8 +18,9 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const argsOpenCases = { - client: context.core.savedObjects.client, + client, options: { fields: [], page: 1, @@ -29,7 +30,7 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { }; const argsClosedCases = { - client: context.core.savedObjects.client, + client, options: { fields: [], page: 1, diff --git a/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts b/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts index ca51f421f4f56..55e8fe2af128c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts +++ b/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts @@ -15,8 +15,9 @@ export function initGetTagsApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const tags = await caseService.getTags({ - client: context.core.savedObjects.client, + client, }); return response.ok({ body: tags }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts new file mode 100644 index 0000000000000..2d4f16e46d561 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +import { CaseUserActionsResponseRt } from '../../../../../common/api'; +import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../../../saved_object_types'; +import { RouteDeps } from '../../types'; +import { wrapError } from '../../utils'; + +export function initGetAllUserActionsApi({ userActionService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/{case_id}/user_actions', + validate: { + params: schema.object({ + case_id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const userActions = await userActionService.getUserActions({ + client, + caseId: request.params.case_id, + }); + return response.ok({ + body: CaseUserActionsResponseRt.encode( + userActions.saved_objects.map(ua => ({ + ...ua.attributes, + action_id: ua.id, + case_id: ua.references.find(r => r.type === CASE_SAVED_OBJECT)?.id ?? '', + comment_id: ua.references.find(r => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null, + })) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index 60ee57a0efea7..ced88fabf3160 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -9,6 +9,11 @@ import { initFindCasesApi } from '././cases/find_cases'; import { initGetCaseApi } from './cases/get_case'; import { initPatchCasesApi } from './cases/patch_cases'; import { initPostCaseApi } from './cases/post_case'; +import { initPushCaseUserActionApi } from './cases/push_case'; +import { initGetReportersApi } from './cases/reporters/get_reporters'; +import { initGetCasesStatusApi } from './cases/status/get_status'; +import { initGetTagsApi } from './cases/tags/get_tags'; +import { initGetAllUserActionsApi } from './cases/user_actions/get_all_user_actions'; import { initDeleteCommentApi } from './cases/comments/delete_comment'; import { initDeleteAllCommentsApi } from './cases/comments/delete_all_comments'; @@ -18,18 +23,13 @@ import { initGetCommentApi } from './cases/comments/get_comment'; import { initPatchCommentApi } from './cases/comments/patch_comment'; import { initPostCommentApi } from './cases/comments/post_comment'; -import { initGetReportersApi } from './cases/reporters/get_reporters'; - -import { initGetCasesStatusApi } from './cases/status/get_status'; - -import { initGetTagsApi } from './cases/tags/get_tags'; - -import { RouteDeps } from './types'; import { initCaseConfigureGetActionConnector } from './cases/configure/get_connectors'; import { initGetCaseConfigure } from './cases/configure/get_configure'; import { initPatchCaseConfigure } from './cases/configure/patch_configure'; import { initPostCaseConfigure } from './cases/configure/post_configure'; +import { RouteDeps } from './types'; + export function initCaseApi(deps: RouteDeps) { // Cases initDeleteCasesApi(deps); @@ -37,6 +37,8 @@ export function initCaseApi(deps: RouteDeps) { initGetCaseApi(deps); initPatchCasesApi(deps); initPostCaseApi(deps); + initPushCaseUserActionApi(deps); + initGetAllUserActionsApi(deps); // Comments initDeleteCommentApi(deps); initDeleteAllCommentsApi(deps); diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index 7af3e7b70d96f..e532a7b618b5c 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -5,11 +5,16 @@ */ import { IRouter } from 'src/core/server'; -import { CaseConfigureServiceSetup, CaseServiceSetup } from '../../services'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + CaseUserActionServiceSetup, +} from '../../services'; export interface RouteDeps { caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; router: IRouter; } @@ -18,3 +23,8 @@ export enum SortFieldCase { createdAt = 'created_at', status = 'status', } + +export interface TotalCommentByCase { + caseId: string; + totalComments: number; +} diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 19dbb024d1e0b..9d90eb8ef4a6d 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -22,7 +22,8 @@ import { CommentsResponse, CommentAttributes, } from '../../../common/api'; -import { SortFieldCase } from './types'; + +import { SortFieldCase, TotalCommentByCase } from './types'; export const transformNewCase = ({ createdDate, @@ -37,11 +38,11 @@ export const transformNewCase = ({ newCase: CaseRequest; username: string; }): CaseAttributes => ({ - closed_at: newCase.status === 'closed' ? createdDate : null, - closed_by: newCase.status === 'closed' ? { email, full_name, username } : null, - comment_ids: [], + closed_at: null, + closed_by: null, created_at: createdDate, created_by: { email, full_name, username }, + external_service: null, updated_at: null, updated_by: null, ...newCase, @@ -64,6 +65,8 @@ export const transformNewComment = ({ comment, created_at: createdDate, created_by: { email, full_name, username }, + pushed_at: null, + pushed_by: null, updated_at: null, updated_by: null, }); @@ -81,30 +84,41 @@ export function wrapError(error: any): CustomHttpResponseOptions export const transformCases = ( cases: SavedObjectsFindResponse, countOpenCases: number, - countClosedCases: number + countClosedCases: number, + totalCommentByCase: TotalCommentByCase[] ): CasesFindResponse => ({ page: cases.page, per_page: cases.per_page, total: cases.total, - cases: flattenCaseSavedObjects(cases.saved_objects), + cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase), count_open_cases: countOpenCases, count_closed_cases: countClosedCases, }); export const flattenCaseSavedObjects = ( - savedObjects: SavedObjectsFindResponse['saved_objects'] + savedObjects: SavedObjectsFindResponse['saved_objects'], + totalCommentByCase: TotalCommentByCase[] ): CaseResponse[] => savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject) => { - return [...acc, flattenCaseSavedObject(savedObject, [])]; + return [ + ...acc, + flattenCaseSavedObject( + savedObject, + [], + totalCommentByCase.find(tc => tc.caseId === savedObject.id)?.totalComments ?? 0 + ), + ]; }, []); export const flattenCaseSavedObject = ( savedObject: SavedObject, - comments: Array> = [] + comments: Array> = [], + totalComment: number = 0 ): CaseResponse => ({ id: savedObject.id, version: savedObject.version ?? '0', comments: flattenCommentSavedObjects(comments), + totalComment, ...savedObject.attributes, }); diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index 8eab040b9ca9c..a4c5dab0feeb7 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -30,9 +30,6 @@ export const caseSavedObjectType: SavedObjectsType = { }, }, }, - comment_ids: { - type: 'keyword', - }, created_at: { type: 'date', }, @@ -52,6 +49,41 @@ export const caseSavedObjectType: SavedObjectsType = { description: { type: 'text', }, + external_service: { + properties: { + pushed_at: { + type: 'date', + }, + pushed_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, + connector_id: { + type: 'keyword', + }, + connector_name: { + type: 'keyword', + }, + external_id: { + type: 'keyword', + }, + external_title: { + type: 'text', + }, + external_url: { + type: 'text', + }, + }, + }, title: { type: 'keyword', }, @@ -61,6 +93,7 @@ export const caseSavedObjectType: SavedObjectsType = { tags: { type: 'keyword', }, + updated_at: { type: 'date', }, diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts index f52da886e7611..8776dd39b11fa 100644 --- a/x-pack/plugins/case/server/saved_object_types/comments.ts +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -33,6 +33,19 @@ export const caseCommentSavedObjectType: SavedObjectsType = { }, }, }, + pushed_at: { + type: 'date', + }, + pushed_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, updated_at: { type: 'date', }, diff --git a/x-pack/plugins/case/server/saved_object_types/configure.ts b/x-pack/plugins/case/server/saved_object_types/configure.ts index 8ea6f6bba7d4f..d66c38b6ea8ff 100644 --- a/x-pack/plugins/case/server/saved_object_types/configure.ts +++ b/x-pack/plugins/case/server/saved_object_types/configure.ts @@ -19,6 +19,9 @@ export const caseConfigureSavedObjectType: SavedObjectsType = { }, created_by: { properties: { + email: { + type: 'keyword', + }, username: { type: 'keyword', }, @@ -30,6 +33,9 @@ export const caseConfigureSavedObjectType: SavedObjectsType = { connector_id: { type: 'keyword', }, + connector_name: { + type: 'keyword', + }, closure_type: { type: 'keyword', }, @@ -38,6 +44,9 @@ export const caseConfigureSavedObjectType: SavedObjectsType = { }, updated_by: { properties: { + email: { + type: 'keyword', + }, username: { type: 'keyword', }, diff --git a/x-pack/plugins/case/server/saved_object_types/index.ts b/x-pack/plugins/case/server/saved_object_types/index.ts index 978b3d35ee5c6..0e4b9fa3e2eee 100644 --- a/x-pack/plugins/case/server/saved_object_types/index.ts +++ b/x-pack/plugins/case/server/saved_object_types/index.ts @@ -7,3 +7,4 @@ export { caseSavedObjectType, CASE_SAVED_OBJECT } from './cases'; export { caseConfigureSavedObjectType, CASE_CONFIGURE_SAVED_OBJECT } from './configure'; export { caseCommentSavedObjectType, CASE_COMMENT_SAVED_OBJECT } from './comments'; +export { caseUserActionSavedObjectType, CASE_USER_ACTION_SAVED_OBJECT } from './user_actions'; diff --git a/x-pack/plugins/case/server/saved_object_types/user_actions.ts b/x-pack/plugins/case/server/saved_object_types/user_actions.ts new file mode 100644 index 0000000000000..b61bfafc3b33c --- /dev/null +++ b/x-pack/plugins/case/server/saved_object_types/user_actions.ts @@ -0,0 +1,47 @@ +/* + * 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 { SavedObjectsType } from 'src/core/server'; + +export const CASE_USER_ACTION_SAVED_OBJECT = 'cases-user-actions'; + +export const caseUserActionSavedObjectType: SavedObjectsType = { + name: CASE_USER_ACTION_SAVED_OBJECT, + hidden: false, + namespaceAgnostic: false, + mappings: { + properties: { + action_field: { + type: 'keyword', + }, + action: { + type: 'keyword', + }, + action_at: { + type: 'date', + }, + action_by: { + properties: { + email: { + type: 'keyword', + }, + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + new_value: { + type: 'text', + }, + old_value: { + type: 'text', + }, + }, + }, +}; diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 4bbffddf63251..09d726228d309 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -24,11 +24,17 @@ import { readReporters } from './reporters/read_reporters'; import { readTags } from './tags/read_tags'; export { CaseConfigureService, CaseConfigureServiceSetup } from './configure'; +export { CaseUserActionService, CaseUserActionServiceSetup } from './user_actions'; -interface ClientArgs { +export interface ClientArgs { client: SavedObjectsClientContract; } +interface PushedArgs { + pushed_at: string; + pushed_by: User; +} + interface GetCaseArgs extends ClientArgs { caseId: string; } @@ -37,7 +43,7 @@ interface GetCasesArgs extends ClientArgs { caseIds: string[]; } -interface GetCommentsArgs extends GetCaseArgs { +interface FindCommentsArgs extends GetCaseArgs { options?: SavedObjectFindOptions; } @@ -47,6 +53,7 @@ interface FindCasesArgs extends ClientArgs { interface GetCommentArgs extends ClientArgs { commentId: string; } + interface PostCaseArgs extends ClientArgs { attributes: CaseAttributes; } @@ -58,7 +65,7 @@ interface PostCommentArgs extends ClientArgs { interface PatchCase { caseId: string; - updatedAttributes: Partial; + updatedAttributes: Partial; version?: string; } type PatchCaseArgs = PatchCase & ClientArgs; @@ -68,10 +75,20 @@ interface PatchCasesArgs extends ClientArgs { } interface UpdateCommentArgs extends ClientArgs { commentId: string; - updatedAttributes: Partial; + updatedAttributes: Partial; version?: string; } +interface PatchComment { + commentId: string; + updatedAttributes: Partial; + version?: string; +} + +interface PatchComments extends ClientArgs { + comments: PatchComment[]; +} + interface GetUserArgs { request: KibanaRequest; response: KibanaResponseFactory; @@ -84,7 +101,7 @@ export interface CaseServiceSetup { deleteCase(args: GetCaseArgs): Promise<{}>; deleteComment(args: GetCommentArgs): Promise<{}>; findCases(args: FindCasesArgs): Promise>; - getAllCaseComments(args: GetCommentsArgs): Promise>; + getAllCaseComments(args: FindCommentsArgs): Promise>; getCase(args: GetCaseArgs): Promise>; getCases(args: GetCasesArgs): Promise>; getComment(args: GetCommentArgs): Promise>; @@ -96,6 +113,7 @@ export interface CaseServiceSetup { patchCase(args: PatchCaseArgs): Promise>; patchCases(args: PatchCasesArgs): Promise>; patchComment(args: UpdateCommentArgs): Promise>; + patchComments(args: PatchComments): Promise>; } export class CaseService { @@ -157,7 +175,7 @@ export class CaseService { throw error; } }, - getAllCaseComments: async ({ client, caseId, options }: GetCommentsArgs) => { + getAllCaseComments: async ({ client, caseId, options }: FindCommentsArgs) => { try { this.log.debug(`Attempting to GET all comments for case ${caseId}`); return await client.find({ @@ -261,5 +279,25 @@ export class CaseService { throw error; } }, + patchComments: async ({ client, comments }: PatchComments) => { + try { + this.log.debug( + `Attempting to UPDATE comments ${comments.map(c => c.commentId).join(', ')}` + ); + return await client.bulkUpdate( + comments.map(c => ({ + type: CASE_COMMENT_SAVED_OBJECT, + id: c.commentId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.debug( + `Error on UPDATE comments ${comments.map(c => c.commentId).join(', ')}: ${error}` + ); + throw error; + } + }, }); } diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts new file mode 100644 index 0000000000000..59d193f0f30d5 --- /dev/null +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -0,0 +1,195 @@ +/* + * 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 { SavedObject, SavedObjectsUpdateResponse } from 'kibana/server'; +import { get } from 'lodash'; + +import { + CaseUserActionAttributes, + UserAction, + UserActionField, + CaseAttributes, + User, +} from '../../../common/api'; +import { isTwoArraysDifference } from '../../routes/api/cases/helpers'; +import { UserActionItem } from '.'; +import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; + +export const transformNewUserAction = ({ + actionField, + action, + actionAt, + email, + full_name, + newValue = null, + oldValue = null, + username, +}: { + actionField: UserActionField; + action: UserAction; + actionAt: string; + email?: string; + full_name?: string; + newValue?: string | null; + oldValue?: string | null; + username: string; +}): CaseUserActionAttributes => ({ + action_field: actionField, + action, + action_at: actionAt, + action_by: { email, full_name, username }, + new_value: newValue, + old_value: oldValue, +}); + +interface BuildCaseUserAction { + action: UserAction; + actionAt: string; + actionBy: User; + caseId: string; + fields: UserActionField | unknown[]; + newValue?: string | unknown; + oldValue?: string | unknown; +} + +interface BuildCommentUserActionItem extends BuildCaseUserAction { + commentId: string; +} + +export const buildCommentUserActionItem = ({ + action, + actionAt, + actionBy, + caseId, + commentId, + fields, + newValue, + oldValue, +}: BuildCommentUserActionItem): UserActionItem => ({ + attributes: transformNewUserAction({ + actionField: fields as UserActionField, + action, + actionAt, + ...actionBy, + newValue: newValue as string, + oldValue: oldValue as string, + }), + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: caseId, + }, + { + type: CASE_COMMENT_SAVED_OBJECT, + name: `associated-${CASE_COMMENT_SAVED_OBJECT}`, + id: commentId, + }, + ], +}); + +export const buildCaseUserActionItem = ({ + action, + actionAt, + actionBy, + caseId, + fields, + newValue, + oldValue, +}: BuildCaseUserAction): UserActionItem => ({ + attributes: transformNewUserAction({ + actionField: fields as UserActionField, + action, + actionAt, + ...actionBy, + newValue: newValue as string, + oldValue: oldValue as string, + }), + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: caseId, + }, + ], +}); + +const userActionFieldsAllowed: UserActionField = [ + 'comment', + 'description', + 'tags', + 'title', + 'status', +]; + +export const buildCaseUserActions = ({ + actionDate, + actionBy, + originalCases, + updatedCases, +}: { + actionDate: string; + actionBy: User; + originalCases: Array>; + updatedCases: Array>; +}): UserActionItem[] => + updatedCases.reduce((acc, updatedItem) => { + const originalItem = originalCases.find(oItem => oItem.id === updatedItem.id); + if (originalItem != null) { + let userActions: UserActionItem[] = []; + const updatedFields = Object.keys(updatedItem.attributes) as UserActionField; + updatedFields.forEach(field => { + if (userActionFieldsAllowed.includes(field)) { + const origValue = get(originalItem, ['attributes', field]); + const updatedValue = get(updatedItem, ['attributes', field]); + const compareValues = isTwoArraysDifference(origValue, updatedValue); + if (compareValues != null) { + if (compareValues.addedItems.length > 0) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'add', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: compareValues.addedItems.join(', '), + }), + ]; + } + if (compareValues.deletedItems.length > 0) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'delete', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: compareValues.deletedItems.join(', '), + }), + ]; + } + } else if (origValue !== updatedValue) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'update', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: updatedValue, + oldValue: origValue, + }), + ]; + } + } + }); + return [...acc, ...userActions]; + } + return acc; + }, []); diff --git a/x-pack/plugins/case/server/services/user_actions/index.ts b/x-pack/plugins/case/server/services/user_actions/index.ts new file mode 100644 index 0000000000000..0e9babf9d81af --- /dev/null +++ b/x-pack/plugins/case/server/services/user_actions/index.ts @@ -0,0 +1,77 @@ +/* + * 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 { + SavedObjectsFindResponse, + Logger, + SavedObjectsBulkResponse, + SavedObjectReference, +} from 'kibana/server'; + +import { CaseUserActionAttributes } from '../../../common/api'; +import { CASE_USER_ACTION_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../saved_object_types'; +import { ClientArgs } from '..'; + +interface GetCaseUserActionArgs extends ClientArgs { + caseId: string; +} + +export interface UserActionItem { + attributes: CaseUserActionAttributes; + references: SavedObjectReference[]; +} + +interface PostCaseUserActionArgs extends ClientArgs { + actions: UserActionItem[]; +} + +export interface CaseUserActionServiceSetup { + getUserActions( + args: GetCaseUserActionArgs + ): Promise>; + postUserActions( + args: PostCaseUserActionArgs + ): Promise>; +} + +export class CaseUserActionService { + constructor(private readonly log: Logger) {} + public setup = async (): Promise => ({ + getUserActions: async ({ client, caseId }: GetCaseUserActionArgs) => { + try { + const caseUserActionInfo = await client.find({ + type: CASE_USER_ACTION_SAVED_OBJECT, + fields: [], + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + page: 1, + perPage: 1, + }); + return await client.find({ + type: CASE_USER_ACTION_SAVED_OBJECT, + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + page: 1, + perPage: caseUserActionInfo.total, + sortField: 'action_at', + sortOrder: 'asc', + }); + } catch (error) { + this.log.debug(`Error on GET case user action: ${error}`); + throw error; + } + }, + postUserActions: async ({ client, actions }: PostCaseUserActionArgs) => { + try { + this.log.debug(`Attempting to POST a new case user action`); + return await client.bulkCreate( + actions.map(action => ({ type: CASE_USER_ACTION_SAVED_OBJECT, ...action })) + ); + } catch (error) { + this.log.debug(`Error on POST a new case user action: ${error}`); + throw error; + } + }, + }); +} From 35b222a8401b4c429fc6eb0fb847ecb65f571cca Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 24 Mar 2020 02:56:04 +0000 Subject: [PATCH 053/179] fix(NA): log rotation watchers usage (#60956) * fix(NA): log rotation watchers usage * docs(NA): add old value to the example * chore(NA): change warning messages --- docs/setup/settings.asciidoc | 4 ++-- src/legacy/server/config/schema.js | 4 ++-- .../server/logging/rotate/log_rotator.test.ts | 10 ++++++---- src/legacy/server/logging/rotate/log_rotator.ts | 16 +++++++++++++--- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 71bb7b81ea420..a72c15190840a 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -193,7 +193,7 @@ that feature would not take any effect. `logging.rotate.everyBytes:`:: [experimental] *Default: 10485760* The maximum size of a log file (that is `not an exact` limit). After the limit is reached, a new log file is generated. The default size limit is 10485760 (10 MB) and -this option should be in the range of 102400 (100KB) to 1073741824 (1GB). +this option should be in the range of 1048576 (1 MB) to 1073741824 (1 GB). `logging.rotate.keepFiles:`:: [experimental] *Default: 7* The number of most recent rotated log files to keep on disk. Older files are deleted during log rotation. The default value is 7. The `logging.rotate.keepFiles` @@ -203,7 +203,7 @@ option has to be in the range of 2 to 1024 files. the `logging.rotate.usePolling` is enabled. That option has to be in the range of 5000 to 3600000 milliseconds. `logging.rotate.usePolling:`:: [experimental] *Default: false* By default we try to understand the best way to monitoring -the log file. However, there is some systems where it could not be always accurate. In those cases, if needed, +the log file and warning about it. Please be aware there are some systems where watch api is not accurate. In those cases, in order to get the feature working, the `polling` method could be used enabling that option. `logging.silent:`:: *Default: false* Set the value of this setting to `true` to diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index a24ffcbaaa49f..769d9ba311281 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -133,8 +133,8 @@ export default () => .keys({ enabled: Joi.boolean().default(false), everyBytes: Joi.number() - // > 100KB - .greater(102399) + // > 1MB + .greater(1048576) // < 1GB .less(1073741825) // 10MB diff --git a/src/legacy/server/logging/rotate/log_rotator.test.ts b/src/legacy/server/logging/rotate/log_rotator.test.ts index c2100546364d4..70842d42f5e1f 100644 --- a/src/legacy/server/logging/rotate/log_rotator.test.ts +++ b/src/legacy/server/logging/rotate/log_rotator.test.ts @@ -204,8 +204,8 @@ describe('LogRotator', () => { expect(logRotator.running).toBe(true); expect(logRotator.usePolling).toBe(false); - const usePolling = await logRotator._shouldUsePolling(); - expect(usePolling).toBe(false); + const shouldUsePolling = await logRotator._shouldUsePolling(); + expect(shouldUsePolling).toBe(false); await logRotator.stop(); }); @@ -231,7 +231,8 @@ describe('LogRotator', () => { await logRotator.start(); expect(logRotator.running).toBe(true); - expect(logRotator.usePolling).toBe(true); + expect(logRotator.usePolling).toBe(false); + expect(logRotator.shouldUsePolling).toBe(true); await logRotator.stop(); }); @@ -257,7 +258,8 @@ describe('LogRotator', () => { await logRotator.start(); expect(logRotator.running).toBe(true); - expect(logRotator.usePolling).toBe(true); + expect(logRotator.usePolling).toBe(false); + expect(logRotator.shouldUsePolling).toBe(true); await logRotator.stop(); jest.useRealTimers(); diff --git a/src/legacy/server/logging/rotate/log_rotator.ts b/src/legacy/server/logging/rotate/log_rotator.ts index 3662910ca5a7b..eeb91fd0f2636 100644 --- a/src/legacy/server/logging/rotate/log_rotator.ts +++ b/src/legacy/server/logging/rotate/log_rotator.ts @@ -50,6 +50,7 @@ export class LogRotator { public usePolling: boolean; public pollingInterval: number; private stalkerUsePollingPolicyTestTimeout: NodeJS.Timeout | null; + public shouldUsePolling: boolean; constructor(config: KibanaConfig, server: Server) { this.config = config; @@ -64,6 +65,7 @@ export class LogRotator { this.stalker = null; this.usePolling = config.get('logging.rotate.usePolling'); this.pollingInterval = config.get('logging.rotate.pollingInterval'); + this.shouldUsePolling = false; this.stalkerUsePollingPolicyTestTimeout = null; } @@ -150,12 +152,20 @@ export class LogRotator { } async _startLogFileSizeMonitor() { - this.usePolling = await this._shouldUsePolling(); + this.usePolling = this.config.get('logging.rotate.usePolling'); + this.shouldUsePolling = await this._shouldUsePolling(); - if (this.usePolling && this.usePolling !== this.config.get('logging.rotate.usePolling')) { + if (this.usePolling && !this.shouldUsePolling) { this.log( ['warning', 'logging:rotate'], - 'The current environment does not support `fs.watch`. Falling back to polling using `fs.watchFile`' + 'Looks like your current environment support a faster algorithm then polling. You can try to disable `usePolling`' + ); + } + + if (!this.usePolling && this.shouldUsePolling) { + this.log( + ['error', 'logging:rotate'], + 'Looks like within your current environment you need to use polling in order to enable log rotator. Please enable `usePolling`' ); } From 6de7f2a62b2b078b703bbe6f18475909e1224f57 Mon Sep 17 00:00:00 2001 From: spalger Date: Mon, 23 Mar 2020 22:26:15 -0700 Subject: [PATCH 054/179] Revert "[APM] Collect telemetry about data/API performance (#51612)" This reverts commit 13baa5156151af258249afc6468f15b53fffeee0. --- src/dev/run_check_lockfile_symlinks.js | 2 - x-pack/legacy/plugins/apm/index.ts | 19 +- x-pack/legacy/plugins/apm/mappings.json | 773 +----------------- x-pack/legacy/plugins/apm/scripts/.gitignore | 1 - .../legacy/plugins/apm/scripts/package.json | 10 - .../apm/scripts/setup-kibana-security.js | 1 - .../apm/scripts/upload-telemetry-data.js | 21 - .../download-telemetry-template.ts | 26 - .../generate-sample-documents.ts | 124 --- .../scripts/upload-telemetry-data/index.ts | 208 ----- .../elasticsearch_fieldnames.test.ts.snap | 48 +- x-pack/plugins/apm/common/agent_name.ts | 44 +- .../apm/common/apm_saved_object_constants.ts | 10 +- .../common/elasticsearch_fieldnames.test.ts | 15 +- .../apm/common/elasticsearch_fieldnames.ts | 11 +- x-pack/plugins/apm/kibana.json | 7 +- x-pack/plugins/apm/server/index.ts | 6 +- .../lib/apm_telemetry/__test__/index.test.ts | 83 ++ .../collect_data_telemetry/index.ts | 77 -- .../collect_data_telemetry/tasks.ts | 725 ---------------- .../apm/server/lib/apm_telemetry/index.ts | 155 +--- .../apm/server/lib/apm_telemetry/types.ts | 118 --- .../server/lib/helpers/setup_request.test.ts | 13 - x-pack/plugins/apm/server/plugin.ts | 37 +- .../server/routes/create_api/index.test.ts | 1 - x-pack/plugins/apm/server/routes/services.ts | 16 + .../apm/typings/elasticsearch/aggregations.ts | 26 +- .../apm/typings/es_schemas/raw/error_raw.ts | 2 - .../typings/es_schemas/raw/fields/observer.ts | 10 - .../apm/typings/es_schemas/raw/span_raw.ts | 2 - .../typings/es_schemas/raw/transaction_raw.ts | 2 - 31 files changed, 206 insertions(+), 2387 deletions(-) delete mode 100644 x-pack/legacy/plugins/apm/scripts/.gitignore delete mode 100644 x-pack/legacy/plugins/apm/scripts/package.json delete mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js delete mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts delete mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts delete mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts create mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts delete mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts delete mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts delete mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/types.ts delete mode 100644 x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts diff --git a/src/dev/run_check_lockfile_symlinks.js b/src/dev/run_check_lockfile_symlinks.js index 6c6fc54638ee8..54a8cdf638a78 100644 --- a/src/dev/run_check_lockfile_symlinks.js +++ b/src/dev/run_check_lockfile_symlinks.js @@ -36,8 +36,6 @@ const IGNORE_FILE_GLOBS = [ '**/*fixtures*/**/*', // cypress isn't used in production, ignore it 'x-pack/legacy/plugins/apm/e2e/*', - // apm scripts aren't used in production, ignore them - 'x-pack/legacy/plugins/apm/scripts/*', ]; run(async ({ log }) => { diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index 594e8a4a7af72..0107997f233fe 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -14,13 +14,7 @@ import mappings from './mappings.json'; export const apm: LegacyPluginInitializer = kibana => { return new kibana.Plugin({ - require: [ - 'kibana', - 'elasticsearch', - 'xpack_main', - 'apm_oss', - 'task_manager' - ], + require: ['kibana', 'elasticsearch', 'xpack_main', 'apm_oss'], id: 'apm', configPrefix: 'xpack.apm', publicDir: resolve(__dirname, 'public'), @@ -77,10 +71,7 @@ export const apm: LegacyPluginInitializer = kibana => { autocreateApmIndexPattern: Joi.boolean().default(true), // service map - serviceMapEnabled: Joi.boolean().default(true), - - // telemetry - telemetryCollectionEnabled: Joi.boolean().default(true) + serviceMapEnabled: Joi.boolean().default(true) }).default(); }, @@ -116,12 +107,10 @@ export const apm: LegacyPluginInitializer = kibana => { } } }); + const apmPlugin = server.newPlatform.setup.plugins .apm as APMPluginContract; - - apmPlugin.registerLegacyAPI({ - server - }); + apmPlugin.registerLegacyAPI({ server }); } }); }; diff --git a/x-pack/legacy/plugins/apm/mappings.json b/x-pack/legacy/plugins/apm/mappings.json index ba4c7a89ceaa8..61bc90da28756 100644 --- a/x-pack/legacy/plugins/apm/mappings.json +++ b/x-pack/legacy/plugins/apm/mappings.json @@ -1,659 +1,20 @@ { - "apm-telemetry": { + "apm-services-telemetry": { "properties": { - "agents": { - "properties": { - "dotnet": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "language": { - "properties": { - "name": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "go": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "java": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "type": "object" - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "js-base": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "type": "object" - }, - "language": { - "properties": { - "name": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "type": "object" - } - } - } - } - }, - "nodejs": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "language": { - "properties": { - "name": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "python": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "ruby": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "rum-js": { - "properties": { - "agent": { - "type": "object" - }, - "service": { - "properties": { - "framework": { - "type": "object" - }, - "language": { - "type": "object" - }, - "runtime": { - "type": "object" - } - } - } - } - } - } - }, - "counts": { - "properties": { - "agent_configuration": { - "properties": { - "all": { - "type": "long" - } - } - }, - "error": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "max_error_groups_per_service": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "max_transaction_groups_per_service": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "metric": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "onboarding": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "services": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "sourcemap": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "span": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "traces": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "transaction": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - } - } - }, - "cardinality": { - "properties": { - "user_agent": { - "properties": { - "original": { - "properties": { - "all_agents": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "rum": { - "properties": { - "1d": { - "type": "long" - } - } - } - } - } - } - }, - "transaction": { - "properties": { - "name": { - "properties": { - "all_agents": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "rum": { - "properties": { - "1d": { - "type": "long" - } - } - } - } - } - } - } - } - }, "has_any_services": { "type": "boolean" }, - "indices": { - "properties": { - "all": { - "properties": { - "total": { - "properties": { - "docs": { - "properties": { - "count": { - "type": "long" - } - } - }, - "store": { - "properties": { - "size_in_bytes": { - "type": "long" - } - } - } - } - } - } - }, - "shards": { - "properties": { - "total": { - "type": "long" - } - } - } - } - }, - "integrations": { - "properties": { - "ml": { - "properties": { - "all_jobs_count": { - "type": "long" - } - } - } - } - }, - "retainment": { - "properties": { - "error": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "metric": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "onboarding": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "span": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "transaction": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, "services_per_agent": { "properties": { - "dotnet": { + "python": { "type": "long", "null_value": 0 }, - "go": { + "java": { "type": "long", "null_value": 0 }, - "java": { + "nodejs": { "type": "long", "null_value": 0 }, @@ -661,11 +22,11 @@ "type": "long", "null_value": 0 }, - "nodejs": { + "rum-js": { "type": "long", "null_value": 0 }, - "python": { + "dotnet": { "type": "long", "null_value": 0 }, @@ -673,131 +34,11 @@ "type": "long", "null_value": 0 }, - "rum-js": { + "go": { "type": "long", "null_value": 0 } } - }, - "tasks": { - "properties": { - "agent_configuration": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "agents": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "cardinality": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "groupings": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "indices_stats": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "integrations": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "processor_events": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "services": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "versions": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - } - } - }, - "version": { - "properties": { - "apm_server": { - "properties": { - "major": { - "type": "long" - }, - "minor": { - "type": "long" - }, - "patch": { - "type": "long" - } - } - } - } } } }, diff --git a/x-pack/legacy/plugins/apm/scripts/.gitignore b/x-pack/legacy/plugins/apm/scripts/.gitignore deleted file mode 100644 index 8ee01d321b721..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/.gitignore +++ /dev/null @@ -1 +0,0 @@ -yarn.lock diff --git a/x-pack/legacy/plugins/apm/scripts/package.json b/x-pack/legacy/plugins/apm/scripts/package.json deleted file mode 100644 index 9121449c53619..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "apm-scripts", - "version": "1.0.0", - "main": "index.js", - "license": "MIT", - "dependencies": { - "@octokit/rest": "^16.35.0", - "console-stamp": "^0.2.9" - } -} diff --git a/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js b/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js index 61ba2fdc7f7e3..825c1a526fcc5 100644 --- a/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js +++ b/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js @@ -16,7 +16,6 @@ ******************************/ // compile typescript on the fly -// eslint-disable-next-line import/no-extraneous-dependencies require('@babel/register')({ extensions: ['.ts'], plugins: ['@babel/plugin-proposal-optional-chaining'], diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js deleted file mode 100644 index a99651c62dd7a..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js +++ /dev/null @@ -1,21 +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. - */ - -// compile typescript on the fly -// eslint-disable-next-line import/no-extraneous-dependencies -require('@babel/register')({ - extensions: ['.ts'], - plugins: [ - '@babel/plugin-proposal-optional-chaining', - '@babel/plugin-proposal-nullish-coalescing-operator' - ], - presets: [ - '@babel/typescript', - ['@babel/preset-env', { targets: { node: 'current' } }] - ] -}); - -require('./upload-telemetry-data/index.ts'); diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts deleted file mode 100644 index dfed9223ef708..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts +++ /dev/null @@ -1,26 +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. - */ - -// @ts-ignore -import { Octokit } from '@octokit/rest'; - -export async function downloadTelemetryTemplate(octokit: Octokit) { - const file = await octokit.repos.getContents({ - owner: 'elastic', - repo: 'telemetry', - path: 'config/templates/xpack-phone-home.json', - // @ts-ignore - mediaType: { - format: 'application/vnd.github.VERSION.raw' - } - }); - - if (Array.isArray(file.data)) { - throw new Error('Expected single response, got array'); - } - - return JSON.parse(Buffer.from(file.data.content!, 'base64').toString()); -} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts deleted file mode 100644 index 8d76063a7fdf6..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { DeepPartial } from 'utility-types'; -import { - merge, - omit, - defaultsDeep, - range, - mapValues, - isPlainObject, - flatten -} from 'lodash'; -import uuid from 'uuid'; -import { - CollectTelemetryParams, - collectDataTelemetry - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; - -interface GenerateOptions { - days: number; - instances: number; - variation: { - min: number; - max: number; - }; -} - -const randomize = ( - value: unknown, - instanceVariation: number, - dailyGrowth: number -) => { - if (typeof value === 'boolean') { - return Math.random() > 0.5; - } - if (typeof value === 'number') { - return Math.round(instanceVariation * dailyGrowth * value); - } - return value; -}; - -const mapValuesDeep = ( - obj: Record, - iterator: (value: unknown, key: string, obj: Record) => unknown -): Record => - mapValues(obj, (val, key) => - isPlainObject(val) ? mapValuesDeep(val, iterator) : iterator(val, key!, obj) - ); - -export async function generateSampleDocuments( - options: DeepPartial & { - collectTelemetryParams: CollectTelemetryParams; - } -) { - const { collectTelemetryParams, ...preferredOptions } = options; - - const opts: GenerateOptions = defaultsDeep( - { - days: 100, - instances: 50, - variation: { - min: 0.1, - max: 4 - } - }, - preferredOptions - ); - - const sample = await collectDataTelemetry(collectTelemetryParams); - - console.log('Collected telemetry'); // eslint-disable-line no-console - console.log('\n' + JSON.stringify(sample, null, 2)); // eslint-disable-line no-console - - const dateOfScriptExecution = new Date(); - - return flatten( - range(0, opts.instances).map(instanceNo => { - const instanceId = uuid.v4(); - const defaults = { - cluster_uuid: instanceId, - stack_stats: { - kibana: { - versions: { - version: '8.0.0' - } - } - } - }; - - const instanceVariation = - Math.random() * (opts.variation.max - opts.variation.min) + - opts.variation.min; - - return range(0, opts.days).map(dayNo => { - const dailyGrowth = Math.pow(1.005, opts.days - 1 - dayNo); - - const timestamp = Date.UTC( - dateOfScriptExecution.getFullYear(), - dateOfScriptExecution.getMonth(), - -dayNo - ); - - const generated = mapValuesDeep(omit(sample, 'versions'), value => - randomize(value, instanceVariation, dailyGrowth) - ); - - return merge({}, defaults, { - timestamp, - stack_stats: { - kibana: { - plugins: { - apm: merge({}, sample, generated) - } - } - } - }); - }); - }) - ); -} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts deleted file mode 100644 index bdc57eac412fc..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts +++ /dev/null @@ -1,208 +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. - */ - -// This script downloads the telemetry mapping, runs the APM telemetry tasks, -// generates a bunch of randomized data based on the downloaded sample, -// and uploads it to a cluster of your choosing in the same format as it is -// stored in the telemetry cluster. Its purpose is twofold: -// - Easier testing of the telemetry tasks -// - Validate whether we can run the queries we want to on the telemetry data - -import fs from 'fs'; -import path from 'path'; -// @ts-ignore -import { Octokit } from '@octokit/rest'; -import { merge, chunk, flatten, pick, identity } from 'lodash'; -import axios from 'axios'; -import yaml from 'js-yaml'; -import { Client } from 'elasticsearch'; -import { argv } from 'yargs'; -import { promisify } from 'util'; -import { Logger } from 'kibana/server'; -// @ts-ignore -import consoleStamp from 'console-stamp'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { CollectTelemetryParams } from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; -import { downloadTelemetryTemplate } from './download-telemetry-template'; -import mapping from '../../mappings.json'; -import { generateSampleDocuments } from './generate-sample-documents'; - -consoleStamp(console, '[HH:MM:ss.l]'); - -const githubToken = process.env.GITHUB_TOKEN; - -if (!githubToken) { - throw new Error('GITHUB_TOKEN was not provided.'); -} - -const kibanaConfigDir = path.join(__filename, '../../../../../../../config'); -const kibanaDevConfig = path.join(kibanaConfigDir, 'kibana.dev.yml'); -const kibanaConfig = path.join(kibanaConfigDir, 'kibana.yml'); - -const xpackTelemetryIndexName = 'xpack-phone-home'; - -const loadedKibanaConfig = (yaml.safeLoad( - fs.readFileSync( - fs.existsSync(kibanaDevConfig) ? kibanaDevConfig : kibanaConfig, - 'utf8' - ) -) || {}) as {}; - -const cliEsCredentials = pick( - { - 'elasticsearch.username': process.env.ELASTICSEARCH_USERNAME, - 'elasticsearch.password': process.env.ELASTICSEARCH_PASSWORD, - 'elasticsearch.hosts': process.env.ELASTICSEARCH_HOST - }, - identity -) as { - 'elasticsearch.username': string; - 'elasticsearch.password': string; - 'elasticsearch.hosts': string; -}; - -const config = { - 'apm_oss.transactionIndices': 'apm-*', - 'apm_oss.metricsIndices': 'apm-*', - 'apm_oss.errorIndices': 'apm-*', - 'apm_oss.spanIndices': 'apm-*', - 'apm_oss.onboardingIndices': 'apm-*', - 'apm_oss.sourcemapIndices': 'apm-*', - 'elasticsearch.hosts': 'http://localhost:9200', - ...loadedKibanaConfig, - ...cliEsCredentials -}; - -async function uploadData() { - const octokit = new Octokit({ - auth: githubToken - }); - - const telemetryTemplate = await downloadTelemetryTemplate(octokit); - - const kibanaMapping = mapping['apm-telemetry']; - - const httpAuth = - config['elasticsearch.username'] && config['elasticsearch.password'] - ? { - username: config['elasticsearch.username'], - password: config['elasticsearch.password'] - } - : null; - - const client = new Client({ - host: config['elasticsearch.hosts'], - ...(httpAuth - ? { - httpAuth: `${httpAuth.username}:${httpAuth.password}` - } - : {}) - }); - - if (argv.clear) { - try { - await promisify(client.indices.delete.bind(client))({ - index: xpackTelemetryIndexName - }); - } catch (err) { - // 404 = index not found, totally okay - if (err.status !== 404) { - throw err; - } - } - } - - const axiosInstance = axios.create({ - baseURL: config['elasticsearch.hosts'], - ...(httpAuth ? { auth: httpAuth } : {}) - }); - - const newTemplate = merge(telemetryTemplate, { - settings: { - index: { mapping: { total_fields: { limit: 10000 } } } - } - }); - - // override apm mapping instead of merging - newTemplate.mappings.properties.stack_stats.properties.kibana.properties.plugins.properties.apm = kibanaMapping; - - await axiosInstance.put(`/_template/xpack-phone-home`, newTemplate); - - const sampleDocuments = await generateSampleDocuments({ - collectTelemetryParams: { - logger: (console as unknown) as Logger, - indices: { - ...config, - apmCustomLinkIndex: '.apm-custom-links', - apmAgentConfigurationIndex: '.apm-agent-configuration' - }, - search: body => { - return promisify(client.search.bind(client))({ - ...body, - requestTimeout: 120000 - }) as any; - }, - indicesStats: body => { - return promisify(client.indices.stats.bind(client))({ - ...body, - requestTimeout: 120000 - }) as any; - }, - transportRequest: (params => { - return axiosInstance[params.method](params.path); - }) as CollectTelemetryParams['transportRequest'] - } - }); - - const chunks = chunk(sampleDocuments, 250); - - await chunks.reduce>((prev, documents) => { - return prev.then(async () => { - const body = flatten( - documents.map(doc => [{ index: { _index: 'xpack-phone-home' } }, doc]) - ); - - return promisify(client.bulk.bind(client))({ - body, - refresh: true - }).then((response: any) => { - if (response.errors) { - const firstError = response.items.filter( - (item: any) => item.index.status >= 400 - )[0].index.error; - throw new Error(`Failed to upload documents: ${firstError.reason} `); - } - }); - }); - }, Promise.resolve()); -} - -uploadData() - .catch(e => { - if ('response' in e) { - if (typeof e.response === 'string') { - // eslint-disable-next-line no-console - console.log(e.response); - } else { - // eslint-disable-next-line no-console - console.log( - JSON.stringify( - e.response, - ['status', 'statusText', 'headers', 'data'], - 2 - ) - ); - } - } else { - // eslint-disable-next-line no-console - console.log(e); - } - process.exit(1); - }) - .then(() => { - // eslint-disable-next-line no-console - console.log('Finished uploading generated telemetry data'); - }); diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 5de82a9ee8788..897d4e979fce3 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -2,8 +2,6 @@ exports[`Error AGENT_NAME 1`] = `"java"`; -exports[`Error AGENT_VERSION 1`] = `"agent version"`; - exports[`Error CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Error CONTAINER_ID 1`] = `undefined`; @@ -58,7 +56,7 @@ exports[`Error METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Error OBSERVER_LISTENING 1`] = `undefined`; -exports[`Error OBSERVER_VERSION_MAJOR 1`] = `8`; +exports[`Error OBSERVER_VERSION_MAJOR 1`] = `undefined`; exports[`Error PARENT_ID 1`] = `"parentId"`; @@ -70,20 +68,10 @@ exports[`Error SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Error SERVICE_FRAMEWORK_NAME 1`] = `undefined`; -exports[`Error SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; - -exports[`Error SERVICE_LANGUAGE_NAME 1`] = `"nodejs"`; - -exports[`Error SERVICE_LANGUAGE_VERSION 1`] = `"v1337"`; - exports[`Error SERVICE_NAME 1`] = `"service name"`; exports[`Error SERVICE_NODE_NAME 1`] = `undefined`; -exports[`Error SERVICE_RUNTIME_NAME 1`] = `undefined`; - -exports[`Error SERVICE_RUNTIME_VERSION 1`] = `undefined`; - exports[`Error SERVICE_VERSION 1`] = `undefined`; exports[`Error SPAN_ACTION 1`] = `undefined`; @@ -124,14 +112,10 @@ exports[`Error URL_FULL 1`] = `undefined`; exports[`Error USER_AGENT_NAME 1`] = `undefined`; -exports[`Error USER_AGENT_ORIGINAL 1`] = `undefined`; - exports[`Error USER_ID 1`] = `undefined`; exports[`Span AGENT_NAME 1`] = `"java"`; -exports[`Span AGENT_VERSION 1`] = `"agent version"`; - exports[`Span CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Span CONTAINER_ID 1`] = `undefined`; @@ -186,7 +170,7 @@ exports[`Span METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Span OBSERVER_LISTENING 1`] = `undefined`; -exports[`Span OBSERVER_VERSION_MAJOR 1`] = `8`; +exports[`Span OBSERVER_VERSION_MAJOR 1`] = `undefined`; exports[`Span PARENT_ID 1`] = `"parentId"`; @@ -198,20 +182,10 @@ exports[`Span SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Span SERVICE_FRAMEWORK_NAME 1`] = `undefined`; -exports[`Span SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; - -exports[`Span SERVICE_LANGUAGE_NAME 1`] = `undefined`; - -exports[`Span SERVICE_LANGUAGE_VERSION 1`] = `undefined`; - exports[`Span SERVICE_NAME 1`] = `"service name"`; exports[`Span SERVICE_NODE_NAME 1`] = `undefined`; -exports[`Span SERVICE_RUNTIME_NAME 1`] = `undefined`; - -exports[`Span SERVICE_RUNTIME_VERSION 1`] = `undefined`; - exports[`Span SERVICE_VERSION 1`] = `undefined`; exports[`Span SPAN_ACTION 1`] = `"my action"`; @@ -252,14 +226,10 @@ exports[`Span URL_FULL 1`] = `undefined`; exports[`Span USER_AGENT_NAME 1`] = `undefined`; -exports[`Span USER_AGENT_ORIGINAL 1`] = `undefined`; - exports[`Span USER_ID 1`] = `undefined`; exports[`Transaction AGENT_NAME 1`] = `"java"`; -exports[`Transaction AGENT_VERSION 1`] = `"agent version"`; - exports[`Transaction CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Transaction CONTAINER_ID 1`] = `"container1234567890abcdef"`; @@ -314,7 +284,7 @@ exports[`Transaction METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Transaction OBSERVER_LISTENING 1`] = `undefined`; -exports[`Transaction OBSERVER_VERSION_MAJOR 1`] = `8`; +exports[`Transaction OBSERVER_VERSION_MAJOR 1`] = `undefined`; exports[`Transaction PARENT_ID 1`] = `"parentId"`; @@ -326,20 +296,10 @@ exports[`Transaction SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Transaction SERVICE_FRAMEWORK_NAME 1`] = `undefined`; -exports[`Transaction SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; - -exports[`Transaction SERVICE_LANGUAGE_NAME 1`] = `"nodejs"`; - -exports[`Transaction SERVICE_LANGUAGE_VERSION 1`] = `"v1337"`; - exports[`Transaction SERVICE_NAME 1`] = `"service name"`; exports[`Transaction SERVICE_NODE_NAME 1`] = `undefined`; -exports[`Transaction SERVICE_RUNTIME_NAME 1`] = `undefined`; - -exports[`Transaction SERVICE_RUNTIME_VERSION 1`] = `undefined`; - exports[`Transaction SERVICE_VERSION 1`] = `undefined`; exports[`Transaction SPAN_ACTION 1`] = `undefined`; @@ -380,6 +340,4 @@ exports[`Transaction URL_FULL 1`] = `"http://www.elastic.co"`; exports[`Transaction USER_AGENT_NAME 1`] = `"Other"`; -exports[`Transaction USER_AGENT_ORIGINAL 1`] = `"test original"`; - exports[`Transaction USER_ID 1`] = `"1337"`; diff --git a/x-pack/plugins/apm/common/agent_name.ts b/x-pack/plugins/apm/common/agent_name.ts index 085828b729ea5..bb68eb88b8e18 100644 --- a/x-pack/plugins/apm/common/agent_name.ts +++ b/x-pack/plugins/apm/common/agent_name.ts @@ -4,40 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AgentName } from '../typings/es_schemas/ui/fields/agent'; - /* * Agent names can be any string. This list only defines the official agents * that we might want to target specifically eg. linking to their documentation * & telemetry reporting. Support additional agent types by appending * definitions in mappings.json (for telemetry), the AgentName type, and the - * AGENT_NAMES array. + * agentNames object. */ +import { AgentName } from '../typings/es_schemas/ui/fields/agent'; -export const AGENT_NAMES: AgentName[] = [ - 'java', - 'js-base', - 'rum-js', - 'dotnet', - 'go', - 'java', - 'nodejs', - 'python', - 'ruby' -]; +const agentNames: { [agentName in AgentName]: agentName } = { + python: 'python', + java: 'java', + nodejs: 'nodejs', + 'js-base': 'js-base', + 'rum-js': 'rum-js', + dotnet: 'dotnet', + ruby: 'ruby', + go: 'go' +}; -export function isAgentName(agentName: string): agentName is AgentName { - return AGENT_NAMES.includes(agentName as AgentName); +export function isAgentName(agentName: string): boolean { + return Object.values(agentNames).includes(agentName as AgentName); } -export function isRumAgentName( - agentName: string | undefined -): agentName is 'js-base' | 'rum-js' { - return agentName === 'js-base' || agentName === 'rum-js'; +export function isRumAgentName(agentName: string | undefined) { + return ( + agentName === agentNames['js-base'] || agentName === agentNames['rum-js'] + ); } -export function isJavaAgentName( - agentName: string | undefined -): agentName is 'java' { - return agentName === 'java'; +export function isJavaAgentName(agentName: string | undefined) { + return agentName === agentNames.java; } diff --git a/x-pack/plugins/apm/common/apm_saved_object_constants.ts b/x-pack/plugins/apm/common/apm_saved_object_constants.ts index 0529d90fe940a..ac43b700117c6 100644 --- a/x-pack/plugins/apm/common/apm_saved_object_constants.ts +++ b/x-pack/plugins/apm/common/apm_saved_object_constants.ts @@ -4,13 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -// the types have to match the names of the saved object mappings -// in /x-pack/legacy/plugins/apm/mappings.json +// APM Services telemetry +export const APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE = + 'apm-services-telemetry'; +export const APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID = 'apm-services-telemetry'; // APM indices export const APM_INDICES_SAVED_OBJECT_TYPE = 'apm-indices'; export const APM_INDICES_SAVED_OBJECT_ID = 'apm-indices'; - -// APM telemetry -export const APM_TELEMETRY_SAVED_OBJECT_TYPE = 'apm-telemetry'; -export const APM_TELEMETRY_SAVED_OBJECT_ID = 'apm-telemetry'; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts index 63fa749cd9f2c..1add2427d16a0 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts @@ -15,10 +15,7 @@ describe('Transaction', () => { const transaction: AllowUnknownProperties = { '@timestamp': new Date().toString(), '@metadata': 'whatever', - observer: { - version: 'whatever', - version_major: 8 - }, + observer: 'whatever', agent: { name: 'java', version: 'agent version' @@ -66,10 +63,7 @@ describe('Span', () => { const span: AllowUnknownProperties = { '@timestamp': new Date().toString(), '@metadata': 'whatever', - observer: { - version: 'whatever', - version_major: 8 - }, + observer: 'whatever', agent: { name: 'java', version: 'agent version' @@ -113,10 +107,7 @@ describe('Span', () => { describe('Error', () => { const errorDoc: AllowUnknownProperties = { '@metadata': 'whatever', - observer: { - version: 'whatever', - version_major: 8 - }, + observer: 'whatever', agent: { name: 'java', version: 'agent version' diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index bc1b346f50da7..822201baddd88 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -4,24 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +export const AGENT_NAME = 'agent.name'; export const SERVICE_NAME = 'service.name'; export const SERVICE_ENVIRONMENT = 'service.environment'; export const SERVICE_FRAMEWORK_NAME = 'service.framework.name'; -export const SERVICE_FRAMEWORK_VERSION = 'service.framework.version'; -export const SERVICE_LANGUAGE_NAME = 'service.language.name'; -export const SERVICE_LANGUAGE_VERSION = 'service.language.version'; -export const SERVICE_RUNTIME_NAME = 'service.runtime.name'; -export const SERVICE_RUNTIME_VERSION = 'service.runtime.version'; export const SERVICE_NODE_NAME = 'service.node.name'; export const SERVICE_VERSION = 'service.version'; - -export const AGENT_NAME = 'agent.name'; -export const AGENT_VERSION = 'agent.version'; - export const URL_FULL = 'url.full'; export const HTTP_REQUEST_METHOD = 'http.request.method'; export const USER_ID = 'user.id'; -export const USER_AGENT_ORIGINAL = 'user_agent.original'; export const USER_AGENT_NAME = 'user_agent.name'; export const DESTINATION_ADDRESS = 'destination.address'; diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index dadb1dff6d7a9..96579377c95e8 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -3,11 +3,8 @@ "server": true, "version": "8.0.0", "kibanaVersion": "kibana", - "configPath": [ - "xpack", - "apm" - ], + "configPath": ["xpack", "apm"], "ui": false, "requiredPlugins": ["apm_oss", "data", "home", "licensing"], - "optionalPlugins": ["cloud", "usageCollection", "taskManager"] + "optionalPlugins": ["cloud", "usageCollection"] } diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 77655568a7e9c..8afdb9e99c1a3 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -29,8 +29,7 @@ export const config = { enabled: schema.boolean({ defaultValue: true }), transactionGroupBucketSize: schema.number({ defaultValue: 100 }), maxTraceItems: schema.number({ defaultValue: 1000 }) - }), - telemetryCollectionEnabled: schema.boolean({ defaultValue: true }) + }) }) }; @@ -63,8 +62,7 @@ export function mergeConfigs( 'xpack.apm.ui.maxTraceItems': apmConfig.ui.maxTraceItems, 'xpack.apm.ui.transactionGroupBucketSize': apmConfig.ui.transactionGroupBucketSize, - 'xpack.apm.autocreateApmIndexPattern': apmConfig.autocreateApmIndexPattern, - 'xpack.apm.telemetryCollectionEnabled': apmConfig.telemetryCollectionEnabled + 'xpack.apm.autocreateApmIndexPattern': apmConfig.autocreateApmIndexPattern }; } diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts new file mode 100644 index 0000000000000..c45c74a791aee --- /dev/null +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts @@ -0,0 +1,83 @@ +/* + * 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 { SavedObjectAttributes } from '../../../../../../../src/core/server'; +import { createApmTelementry, storeApmServicesTelemetry } from '../index'; +import { + APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, + APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID +} from '../../../../common/apm_saved_object_constants'; + +describe('apm_telemetry', () => { + describe('createApmTelementry', () => { + it('should create a ApmTelemetry object with boolean flag and frequency map of the given list of AgentNames', () => { + const apmTelemetry = createApmTelementry([ + 'go', + 'nodejs', + 'go', + 'js-base' + ]); + expect(apmTelemetry.has_any_services).toBe(true); + expect(apmTelemetry.services_per_agent).toMatchObject({ + go: 2, + nodejs: 1, + 'js-base': 1 + }); + }); + it('should ignore undefined or unknown AgentName values', () => { + const apmTelemetry = createApmTelementry([ + 'go', + 'nodejs', + 'go', + 'js-base', + 'example-platform' as any, + undefined as any + ]); + expect(apmTelemetry.services_per_agent).toMatchObject({ + go: 2, + nodejs: 1, + 'js-base': 1 + }); + }); + }); + + describe('storeApmServicesTelemetry', () => { + let apmTelemetry: SavedObjectAttributes; + let savedObjectsClient: any; + + beforeEach(() => { + apmTelemetry = { + has_any_services: true, + services_per_agent: { + go: 2, + nodejs: 1, + 'js-base': 1 + } + }; + savedObjectsClient = { create: jest.fn() }; + }); + + it('should call savedObjectsClient create with the given ApmTelemetry object', () => { + storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); + expect(savedObjectsClient.create.mock.calls[0][1]).toBe(apmTelemetry); + }); + + it('should call savedObjectsClient create with the apm-telemetry document type and ID', () => { + storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); + expect(savedObjectsClient.create.mock.calls[0][0]).toBe( + APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE + ); + expect(savedObjectsClient.create.mock.calls[0][2].id).toBe( + APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID + ); + }); + + it('should call savedObjectsClient create with overwrite: true', () => { + storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); + expect(savedObjectsClient.create.mock.calls[0][2].overwrite).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts deleted file mode 100644 index 729ccb73d73f3..0000000000000 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts +++ /dev/null @@ -1,77 +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 { merge } from 'lodash'; -import { Logger, CallAPIOptions } from 'kibana/server'; -import { IndicesStatsParams, Client } from 'elasticsearch'; -import { - ESSearchRequest, - ESSearchResponse -} from '../../../../typings/elasticsearch'; -import { ApmIndicesConfig } from '../../settings/apm_indices/get_apm_indices'; -import { tasks } from './tasks'; -import { APMDataTelemetry } from '../types'; - -type TelemetryTaskExecutor = (params: { - indices: ApmIndicesConfig; - search( - params: TSearchRequest - ): Promise>; - indicesStats( - params: IndicesStatsParams, - options?: CallAPIOptions - ): ReturnType; - transportRequest: (params: { - path: string; - method: 'get'; - }) => Promise; -}) => Promise; - -export interface TelemetryTask { - name: string; - executor: TelemetryTaskExecutor; -} - -export type CollectTelemetryParams = Parameters[0] & { - logger: Logger; -}; - -export function collectDataTelemetry({ - search, - indices, - logger, - indicesStats, - transportRequest -}: CollectTelemetryParams) { - return tasks.reduce((prev, task) => { - return prev.then(async data => { - logger.debug(`Executing APM telemetry task ${task.name}`); - try { - const time = process.hrtime(); - const next = await task.executor({ - search, - indices, - indicesStats, - transportRequest - }); - const took = process.hrtime(time); - - return merge({}, data, next, { - tasks: { - [task.name]: { - took: { - ms: Math.round(took[0] * 1000 + took[1] / 1e6) - } - } - } - }); - } catch (err) { - logger.warn(`Failed executing APM telemetry task ${task.name}`); - logger.warn(err); - return data; - } - }); - }, Promise.resolve({} as APMDataTelemetry)); -} diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts deleted file mode 100644 index 415076b6ae116..0000000000000 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts +++ /dev/null @@ -1,725 +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 { flatten, merge, sortBy, sum } from 'lodash'; -import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; -import { AGENT_NAMES } from '../../../../common/agent_name'; -import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; -import { - PROCESSOR_EVENT, - SERVICE_NAME, - AGENT_NAME, - AGENT_VERSION, - ERROR_GROUP_ID, - TRANSACTION_NAME, - PARENT_ID, - SERVICE_FRAMEWORK_NAME, - SERVICE_FRAMEWORK_VERSION, - SERVICE_LANGUAGE_NAME, - SERVICE_LANGUAGE_VERSION, - SERVICE_RUNTIME_NAME, - SERVICE_RUNTIME_VERSION, - USER_AGENT_ORIGINAL -} from '../../../../common/elasticsearch_fieldnames'; -import { Span } from '../../../../typings/es_schemas/ui/span'; -import { APMError } from '../../../../typings/es_schemas/ui/apm_error'; -import { TelemetryTask } from '.'; -import { APMTelemetry } from '../types'; - -const TIME_RANGES = ['1d', 'all'] as const; -type TimeRange = typeof TIME_RANGES[number]; - -export const tasks: TelemetryTask[] = [ - { - name: 'processor_events', - executor: async ({ indices, search }) => { - const indicesByProcessorEvent = { - error: indices['apm_oss.errorIndices'], - metric: indices['apm_oss.metricsIndices'], - span: indices['apm_oss.spanIndices'], - transaction: indices['apm_oss.transactionIndices'], - onboarding: indices['apm_oss.onboardingIndices'], - sourcemap: indices['apm_oss.sourcemapIndices'] - }; - - type ProcessorEvent = keyof typeof indicesByProcessorEvent; - - const jobs: Array<{ - processorEvent: ProcessorEvent; - timeRange: TimeRange; - }> = flatten( - (Object.keys( - indicesByProcessorEvent - ) as ProcessorEvent[]).map(processorEvent => - TIME_RANGES.map(timeRange => ({ processorEvent, timeRange })) - ) - ); - - const allData = await jobs.reduce((prevJob, current) => { - return prevJob.then(async data => { - const { processorEvent, timeRange } = current; - - const response = await search({ - index: indicesByProcessorEvent[processorEvent], - body: { - size: 1, - query: { - bool: { - filter: [ - { term: { [PROCESSOR_EVENT]: processorEvent } }, - ...(timeRange !== 'all' - ? [ - { - range: { - '@timestamp': { - gte: `now-${timeRange}` - } - } - } - ] - : []) - ] - } - }, - sort: { - '@timestamp': 'asc' - }, - _source: ['@timestamp'], - track_total_hits: true - } - }); - - const event = response.hits.hits[0]?._source as { - '@timestamp': number; - }; - - return merge({}, data, { - counts: { - [processorEvent]: { - [timeRange]: response.hits.total.value - } - }, - ...(timeRange === 'all' && event - ? { - retainment: { - [processorEvent]: { - ms: - new Date().getTime() - - new Date(event['@timestamp']).getTime() - } - } - } - : {}) - }); - }); - }, Promise.resolve({} as Record> }>)); - - return allData; - } - }, - { - name: 'agent_configuration', - executor: async ({ indices, search }) => { - const agentConfigurationCount = ( - await search({ - index: indices.apmAgentConfigurationIndex, - body: { - size: 0, - track_total_hits: true - } - }) - ).hits.total.value; - - return { - counts: { - agent_configuration: { - all: agentConfigurationCount - } - } - }; - } - }, - { - name: 'services', - executor: async ({ indices, search }) => { - const servicesPerAgent = await AGENT_NAMES.reduce( - (prevJob, agentName) => { - return prevJob.then(async data => { - const response = await search({ - index: [ - indices['apm_oss.errorIndices'], - indices['apm_oss.spanIndices'], - indices['apm_oss.metricsIndices'], - indices['apm_oss.transactionIndices'] - ], - body: { - size: 0, - query: { - bool: { - filter: [ - { - term: { - [AGENT_NAME]: agentName - } - }, - { - range: { - '@timestamp': { - gte: 'now-1d' - } - } - } - ] - } - }, - aggs: { - services: { - cardinality: { - field: SERVICE_NAME - } - } - } - } - }); - - return { - ...data, - [agentName]: response.aggregations?.services.value || 0 - }; - }); - }, - Promise.resolve({} as Record) - ); - - return { - has_any_services: sum(Object.values(servicesPerAgent)) > 0, - services_per_agent: servicesPerAgent - }; - } - }, - { - name: 'versions', - executor: async ({ search, indices }) => { - const response = await search({ - index: [ - indices['apm_oss.transactionIndices'], - indices['apm_oss.spanIndices'], - indices['apm_oss.errorIndices'] - ], - terminateAfter: 1, - body: { - query: { - exists: { - field: 'observer.version' - } - }, - size: 1, - sort: { - '@timestamp': 'desc' - } - } - }); - - const hit = response.hits.hits[0]?._source as Pick< - Transaction | Span | APMError, - 'observer' - >; - - if (!hit || !hit.observer?.version) { - return {}; - } - - const [major, minor, patch] = hit.observer.version - .split('.') - .map(part => Number(part)); - - return { - versions: { - apm_server: { - major, - minor, - patch - } - } - }; - } - }, - { - name: 'groupings', - executor: async ({ search, indices }) => { - const range1d = { range: { '@timestamp': { gte: 'now-1d' } } }; - const errorGroupsCount = ( - await search({ - index: indices['apm_oss.errorIndices'], - body: { - size: 0, - query: { - bool: { - filter: [{ term: { [PROCESSOR_EVENT]: 'error' } }, range1d] - } - }, - aggs: { - top_service: { - terms: { - field: SERVICE_NAME, - order: { - error_groups: 'desc' - }, - size: 1 - }, - aggs: { - error_groups: { - cardinality: { - field: ERROR_GROUP_ID - } - } - } - } - } - } - }) - ).aggregations?.top_service.buckets[0]?.error_groups.value; - - const transactionGroupsCount = ( - await search({ - index: indices['apm_oss.transactionIndices'], - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [PROCESSOR_EVENT]: 'transaction' } }, - range1d - ] - } - }, - aggs: { - top_service: { - terms: { - field: SERVICE_NAME, - order: { - transaction_groups: 'desc' - }, - size: 1 - }, - aggs: { - transaction_groups: { - cardinality: { - field: TRANSACTION_NAME - } - } - } - } - } - } - }) - ).aggregations?.top_service.buckets[0]?.transaction_groups.value; - - const tracesPerDayCount = ( - await search({ - index: indices['apm_oss.transactionIndices'], - body: { - query: { - bool: { - filter: [ - { term: { [PROCESSOR_EVENT]: 'transaction' } }, - range1d - ], - must_not: { - exists: { field: PARENT_ID } - } - } - }, - track_total_hits: true, - size: 0 - } - }) - ).hits.total.value; - - const servicesCount = ( - await search({ - index: [ - indices['apm_oss.transactionIndices'], - indices['apm_oss.errorIndices'], - indices['apm_oss.metricsIndices'] - ], - body: { - size: 0, - query: { - bool: { - filter: [range1d] - } - }, - aggs: { - service_name: { - cardinality: { - field: SERVICE_NAME - } - } - } - } - }) - ).aggregations?.service_name.value; - - return { - counts: { - max_error_groups_per_service: { - '1d': errorGroupsCount || 0 - }, - max_transaction_groups_per_service: { - '1d': transactionGroupsCount || 0 - }, - traces: { - '1d': tracesPerDayCount || 0 - }, - services: { - '1d': servicesCount || 0 - } - } - }; - } - }, - { - name: 'integrations', - executor: async ({ transportRequest }) => { - const apmJobs = ['*-high_mean_response_time']; - - const response = (await transportRequest({ - method: 'get', - path: `/_ml/anomaly_detectors/${apmJobs.join(',')}` - })) as { data?: { count: number } }; - - return { - integrations: { - ml: { - all_jobs_count: response.data?.count ?? 0 - } - } - }; - } - }, - { - name: 'agents', - executor: async ({ search, indices }) => { - const size = 3; - - const agentData = await AGENT_NAMES.reduce(async (prevJob, agentName) => { - const data = await prevJob; - - const response = await search({ - index: [ - indices['apm_oss.errorIndices'], - indices['apm_oss.metricsIndices'], - indices['apm_oss.transactionIndices'] - ], - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [AGENT_NAME]: agentName } }, - { range: { '@timestamp': { gte: 'now-1d' } } } - ] - } - }, - sort: { - '@timestamp': 'desc' - }, - aggs: { - [AGENT_VERSION]: { - terms: { - field: AGENT_VERSION, - size - } - }, - [SERVICE_FRAMEWORK_NAME]: { - terms: { - field: SERVICE_FRAMEWORK_NAME, - size - }, - aggs: { - [SERVICE_FRAMEWORK_VERSION]: { - terms: { - field: SERVICE_FRAMEWORK_VERSION, - size - } - } - } - }, - [SERVICE_FRAMEWORK_VERSION]: { - terms: { - field: SERVICE_FRAMEWORK_VERSION, - size - } - }, - [SERVICE_LANGUAGE_NAME]: { - terms: { - field: SERVICE_LANGUAGE_NAME, - size - }, - aggs: { - [SERVICE_LANGUAGE_VERSION]: { - terms: { - field: SERVICE_LANGUAGE_VERSION, - size - } - } - } - }, - [SERVICE_LANGUAGE_VERSION]: { - terms: { - field: SERVICE_LANGUAGE_VERSION, - size - } - }, - [SERVICE_RUNTIME_NAME]: { - terms: { - field: SERVICE_RUNTIME_NAME, - size - }, - aggs: { - [SERVICE_RUNTIME_VERSION]: { - terms: { - field: SERVICE_RUNTIME_VERSION, - size - } - } - } - }, - [SERVICE_RUNTIME_VERSION]: { - terms: { - field: SERVICE_RUNTIME_VERSION, - size - } - } - } - } - }); - - const { aggregations } = response; - - if (!aggregations) { - return data; - } - - const toComposite = ( - outerKey: string | number, - innerKey: string | number - ) => `${outerKey}/${innerKey}`; - - return { - ...data, - [agentName]: { - agent: { - version: aggregations[AGENT_VERSION].buckets.map( - bucket => bucket.key as string - ) - }, - service: { - framework: { - name: aggregations[SERVICE_FRAMEWORK_NAME].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - version: aggregations[SERVICE_FRAMEWORK_VERSION].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - composite: sortBy( - flatten( - aggregations[SERVICE_FRAMEWORK_NAME].buckets.map(bucket => - bucket[SERVICE_FRAMEWORK_VERSION].buckets.map( - versionBucket => ({ - doc_count: versionBucket.doc_count, - name: toComposite(bucket.key, versionBucket.key) - }) - ) - ) - ), - 'doc_count' - ) - .reverse() - .slice(0, size) - .map(composite => composite.name) - }, - language: { - name: aggregations[SERVICE_LANGUAGE_NAME].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - version: aggregations[SERVICE_LANGUAGE_VERSION].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - composite: sortBy( - flatten( - aggregations[SERVICE_LANGUAGE_NAME].buckets.map(bucket => - bucket[SERVICE_LANGUAGE_VERSION].buckets.map( - versionBucket => ({ - doc_count: versionBucket.doc_count, - name: toComposite(bucket.key, versionBucket.key) - }) - ) - ) - ), - 'doc_count' - ) - .reverse() - .slice(0, size) - .map(composite => composite.name) - }, - runtime: { - name: aggregations[SERVICE_RUNTIME_NAME].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - version: aggregations[SERVICE_RUNTIME_VERSION].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - composite: sortBy( - flatten( - aggregations[SERVICE_RUNTIME_NAME].buckets.map(bucket => - bucket[SERVICE_RUNTIME_VERSION].buckets.map( - versionBucket => ({ - doc_count: versionBucket.doc_count, - name: toComposite(bucket.key, versionBucket.key) - }) - ) - ) - ), - 'doc_count' - ) - .reverse() - .slice(0, size) - .map(composite => composite.name) - } - } - } - }; - }, Promise.resolve({} as APMTelemetry['agents'])); - - return { - agents: agentData - }; - } - }, - { - name: 'indices_stats', - executor: async ({ indicesStats, indices }) => { - const response = await indicesStats({ - index: [ - indices.apmAgentConfigurationIndex, - indices['apm_oss.errorIndices'], - indices['apm_oss.metricsIndices'], - indices['apm_oss.onboardingIndices'], - indices['apm_oss.sourcemapIndices'], - indices['apm_oss.spanIndices'], - indices['apm_oss.transactionIndices'] - ] - }); - - return { - indices: { - shards: { - total: response._shards.total - }, - all: { - total: { - docs: { - count: response._all.total.docs.count - }, - store: { - size_in_bytes: response._all.total.store.size_in_bytes - } - } - } - } - }; - } - }, - { - name: 'cardinality', - executor: async ({ search }) => { - const allAgentsCardinalityResponse = await search({ - body: { - size: 0, - query: { - bool: { - filter: [{ range: { '@timestamp': { gte: 'now-1d' } } }] - } - }, - aggs: { - [TRANSACTION_NAME]: { - cardinality: { - field: TRANSACTION_NAME - } - }, - [USER_AGENT_ORIGINAL]: { - cardinality: { - field: USER_AGENT_ORIGINAL - } - } - } - } - }); - - const rumAgentCardinalityResponse = await search({ - body: { - size: 0, - query: { - bool: { - filter: [ - { range: { '@timestamp': { gte: 'now-1d' } } }, - { terms: { [AGENT_NAME]: ['rum-js', 'js-base'] } } - ] - } - }, - aggs: { - [TRANSACTION_NAME]: { - cardinality: { - field: TRANSACTION_NAME - } - }, - [USER_AGENT_ORIGINAL]: { - cardinality: { - field: USER_AGENT_ORIGINAL - } - } - } - } - }); - - return { - cardinality: { - transaction: { - name: { - all_agents: { - '1d': - allAgentsCardinalityResponse.aggregations?.[TRANSACTION_NAME] - .value - }, - rum: { - '1d': - rumAgentCardinalityResponse.aggregations?.[TRANSACTION_NAME] - .value - } - } - }, - user_agent: { - original: { - all_agents: { - '1d': - allAgentsCardinalityResponse.aggregations?.[ - USER_AGENT_ORIGINAL - ].value - }, - rum: { - '1d': - rumAgentCardinalityResponse.aggregations?.[ - USER_AGENT_ORIGINAL - ].value - } - } - } - } - }; - } - } -]; diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts index c80057a2894dc..a2b0494730826 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts @@ -3,127 +3,60 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, Logger } from 'src/core/server'; -import { Observable } from 'rxjs'; -import { take } from 'rxjs/operators'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { - TaskManagerStartContract, - TaskManagerSetupContract -} from '../../../../task_manager/server'; -import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; + +import { countBy } from 'lodash'; +import { SavedObjectAttributes } from '../../../../../../src/core/server'; +import { isAgentName } from '../../../common/agent_name'; import { - APM_TELEMETRY_SAVED_OBJECT_ID, - APM_TELEMETRY_SAVED_OBJECT_TYPE + APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, + APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID } from '../../../common/apm_saved_object_constants'; -import { - collectDataTelemetry, - CollectTelemetryParams -} from './collect_data_telemetry'; -import { APMConfig } from '../..'; -import { getInternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; - -const APM_TELEMETRY_TASK_NAME = 'apm-telemetry-task'; - -export async function createApmTelemetry({ - core, - config$, - usageCollector, - taskManager, - logger -}: { - core: CoreSetup; - config$: Observable; - usageCollector: UsageCollectionSetup; - taskManager: TaskManagerSetupContract; - logger: Logger; -}) { - const savedObjectsClient = await getInternalSavedObjectsClient(core); - - const collectAndStore = async () => { - const config = await config$.pipe(take(1)).toPromise(); - const esClient = core.elasticsearch.dataClient; - - const indices = await getApmIndices({ - config, - savedObjectsClient - }); - - const search = esClient.callAsInternalUser.bind( - esClient, - 'search' - ) as CollectTelemetryParams['search']; - - const indicesStats = esClient.callAsInternalUser.bind( - esClient, - 'indices.stats' - ) as CollectTelemetryParams['indicesStats']; - - const transportRequest = esClient.callAsInternalUser.bind( - esClient, - 'transport.request' - ) as CollectTelemetryParams['transportRequest']; - - const dataTelemetry = await collectDataTelemetry({ - search, - indices, - logger, - indicesStats, - transportRequest - }); - - await savedObjectsClient.create( - APM_TELEMETRY_SAVED_OBJECT_TYPE, - dataTelemetry, - { id: APM_TELEMETRY_SAVED_OBJECT_TYPE, overwrite: true } - ); +import { UsageCollectionSetup } from '../../../../../../src/plugins/usage_collection/server'; +import { InternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; + +export function createApmTelementry( + agentNames: string[] = [] +): SavedObjectAttributes { + const validAgentNames = agentNames.filter(isAgentName); + return { + has_any_services: validAgentNames.length > 0, + services_per_agent: countBy(validAgentNames) }; +} - taskManager.registerTaskDefinitions({ - [APM_TELEMETRY_TASK_NAME]: { - title: 'Collect APM telemetry', - type: APM_TELEMETRY_TASK_NAME, - createTaskRunner: () => { - return { - run: async () => { - await collectAndStore(); - } - }; - } +export async function storeApmServicesTelemetry( + savedObjectsClient: InternalSavedObjectsClient, + apmTelemetry: SavedObjectAttributes +) { + return savedObjectsClient.create( + APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, + apmTelemetry, + { + id: APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID, + overwrite: true } - }); + ); +} - const collector = usageCollector.makeUsageCollector({ +export function makeApmUsageCollector( + usageCollector: UsageCollectionSetup, + savedObjectsRepository: InternalSavedObjectsClient +) { + const apmUsageCollector = usageCollector.makeUsageCollector({ type: 'apm', fetch: async () => { - const data = ( - await savedObjectsClient.get( - APM_TELEMETRY_SAVED_OBJECT_TYPE, - APM_TELEMETRY_SAVED_OBJECT_ID - ) - ).attributes; - - return data; + try { + const apmTelemetrySavedObject = await savedObjectsRepository.get( + APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, + APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID + ); + return apmTelemetrySavedObject.attributes; + } catch (err) { + return createApmTelementry(); + } }, isReady: () => true }); - usageCollector.registerCollector(collector); - - core.getStartServices().then(([coreStart, pluginsStart]) => { - const { taskManager: taskManagerStart } = pluginsStart as { - taskManager: TaskManagerStartContract; - }; - - taskManagerStart.ensureScheduled({ - id: APM_TELEMETRY_TASK_NAME, - taskType: APM_TELEMETRY_TASK_NAME, - schedule: { - interval: '720m' - }, - scope: ['apm'], - params: {}, - state: {} - }); - }); + usageCollector.registerCollector(apmUsageCollector); } diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts deleted file mode 100644 index f68dc517a2227..0000000000000 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts +++ /dev/null @@ -1,118 +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 { DeepPartial } from 'utility-types'; -import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; - -export interface TimeframeMap { - '1d': number; - all: number; -} - -export type TimeframeMap1d = Pick; -export type TimeframeMapAll = Pick; - -export type APMDataTelemetry = DeepPartial<{ - has_any_services: boolean; - services_per_agent: Record; - versions: { - apm_server: { - minor: number; - major: number; - patch: number; - }; - }; - counts: { - transaction: TimeframeMap; - span: TimeframeMap; - error: TimeframeMap; - metric: TimeframeMap; - sourcemap: TimeframeMap; - onboarding: TimeframeMap; - agent_configuration: TimeframeMapAll; - max_transaction_groups_per_service: TimeframeMap; - max_error_groups_per_service: TimeframeMap; - traces: TimeframeMap; - services: TimeframeMap; - }; - cardinality: { - user_agent: { - original: { - all_agents: TimeframeMap1d; - rum: TimeframeMap1d; - }; - }; - transaction: { - name: { - all_agents: TimeframeMap1d; - rum: TimeframeMap1d; - }; - }; - }; - retainment: Record< - 'span' | 'transaction' | 'error' | 'metric' | 'sourcemap' | 'onboarding', - { ms: number } - >; - integrations: { - ml: { - all_jobs_count: number; - }; - }; - agents: Record< - AgentName, - { - agent: { - version: string[]; - }; - service: { - framework: { - name: string[]; - version: string[]; - composite: string[]; - }; - language: { - name: string[]; - version: string[]; - composite: string[]; - }; - runtime: { - name: string[]; - version: string[]; - composite: string[]; - }; - }; - } - >; - indices: { - shards: { - total: number; - }; - all: { - total: { - docs: { - count: number; - }; - store: { - size_in_bytes: number; - }; - }; - }; - }; - tasks: Record< - | 'processor_events' - | 'agent_configuration' - | 'services' - | 'versions' - | 'groupings' - | 'integrations' - | 'agents' - | 'indices_stats' - | 'cardinality', - { took: { ms: number } } - >; -}>; - -export type APMTelemetry = APMDataTelemetry; diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index 8e8cf698a84cf..40a2a0e7216a0 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -39,19 +39,6 @@ function getMockRequest() { _debug: false } }, - __LEGACY: { - server: { - plugins: { - elasticsearch: { - getCluster: jest.fn().mockReturnValue({ callWithInternalUser: {} }) - } - }, - savedObjects: { - SavedObjectsClient: jest.fn(), - getSavedObjectsRepository: jest.fn() - } - } - }, core: { elasticsearch: { dataClient: { diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index a29b9399d8435..db14730f802a9 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -8,9 +8,9 @@ import { Observable, combineLatest, AsyncSubject } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { Server } from 'hapi'; import { once } from 'lodash'; -import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; -import { TaskManagerSetupContract } from '../../task_manager/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; +import { makeApmUsageCollector } from './lib/apm_telemetry'; import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; import { createApmApi } from './routes/create_apm_api'; @@ -21,7 +21,6 @@ import { tutorialProvider } from './tutorial'; import { CloudSetup } from '../../cloud/server'; import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; import { LicensingPluginSetup } from '../../licensing/public'; -import { createApmTelemetry } from './lib/apm_telemetry'; export interface LegacySetup { server: Server; @@ -48,10 +47,9 @@ export class APMPlugin implements Plugin { licensing: LicensingPluginSetup; cloud?: CloudSetup; usageCollection?: UsageCollectionSetup; - taskManager?: TaskManagerSetupContract; } ) { - const logger = this.initContext.logger.get(); + const logger = this.initContext.logger.get('apm'); const config$ = this.initContext.config.create(); const mergedConfig$ = combineLatest(plugins.apm_oss.config$, config$).pipe( map(([apmOssConfig, apmConfig]) => mergeConfigs(apmOssConfig, apmConfig)) @@ -63,20 +61,6 @@ export class APMPlugin implements Plugin { const currentConfig = await mergedConfig$.pipe(take(1)).toPromise(); - if ( - plugins.taskManager && - plugins.usageCollection && - currentConfig['xpack.apm.telemetryCollectionEnabled'] - ) { - createApmTelemetry({ - core, - config$: mergedConfig$, - usageCollector: plugins.usageCollection, - taskManager: plugins.taskManager, - logger - }); - } - // create agent configuration index without blocking setup lifecycle createApmAgentConfigurationIndex({ esClient: core.elasticsearch.dataClient, @@ -105,6 +89,18 @@ export class APMPlugin implements Plugin { }) ); + const usageCollection = plugins.usageCollection; + if (usageCollection) { + getInternalSavedObjectsClient(core) + .then(savedObjectsClient => { + makeApmUsageCollector(usageCollection, savedObjectsClient); + }) + .catch(error => { + logger.error('Unable to initialize use collection'); + logger.error(error.message); + }); + } + return { config$: mergedConfig$, registerLegacyAPI: once((__LEGACY: LegacySetup) => { @@ -119,7 +115,6 @@ export class APMPlugin implements Plugin { }; } - public async start() {} - + public start() {} public stop() {} } diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts index 312dae1d1f9d2..e639bb5101e2f 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.test.ts @@ -36,7 +36,6 @@ const getCoreMock = () => { put, createRouter, context: { - measure: () => undefined, config$: new BehaviorSubject({} as APMConfig), logger: ({ error: jest.fn() diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 1c6561ee24c93..2d4fae9d2707a 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -5,6 +5,11 @@ */ import * as t from 'io-ts'; +import { AgentName } from '../../typings/es_schemas/ui/fields/agent'; +import { + createApmTelementry, + storeApmServicesTelemetry +} from '../lib/apm_telemetry'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceAgentName } from '../lib/services/get_service_agent_name'; import { getServices } from '../lib/services/get_services'; @@ -13,6 +18,7 @@ import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadat import { createRoute } from './create_route'; import { uiFiltersRt, rangeRt } from './default_api_types'; import { getServiceAnnotations } from '../lib/services/annotations'; +import { getInternalSavedObjectsClient } from '../lib/helpers/get_internal_saved_objects_client'; export const servicesRoute = createRoute(core => ({ path: '/api/apm/services', @@ -23,6 +29,16 @@ export const servicesRoute = createRoute(core => ({ const setup = await setupRequest(context, request); const services = await getServices(setup); + // Store telemetry data derived from services + const agentNames = services.items.map( + ({ agentName }) => agentName as AgentName + ); + const apmTelemetry = createApmTelementry(agentNames); + const savedObjectsClient = await getInternalSavedObjectsClient(core); + storeApmServicesTelemetry(savedObjectsClient, apmTelemetry).catch(error => { + context.logger.error(error.message); + }); + return services; } })); diff --git a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts index 8a8d256cf4273..6d3620f11a87b 100644 --- a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts +++ b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts @@ -126,16 +126,6 @@ export interface AggregationOptionsByType { combine_script: Script; reduce_script: Script; }; - date_range: { - field: string; - format?: string; - ranges: Array< - | { from: string | number } - | { to: string | number } - | { from: string | number; to: string | number } - >; - keyed?: boolean; - }; } type AggregationType = keyof AggregationOptionsByType; @@ -146,15 +136,6 @@ type AggregationOptionsMap = Unionize< } > & { aggs?: AggregationInputMap }; -interface DateRangeBucket { - key: string; - to?: number; - from?: number; - to_as_string?: string; - from_as_string?: string; - doc_count: number; -} - export interface AggregationInputMap { [key: string]: AggregationOptionsMap; } @@ -295,11 +276,6 @@ interface AggregationResponsePart< scripted_metric: { value: unknown; }; - date_range: { - buckets: TAggregationOptionsMap extends { date_range: { keyed: true } } - ? Record - : { buckets: DateRangeBucket[] }; - }; } // Type for debugging purposes. If you see an error in AggregationResponseMap @@ -309,7 +285,7 @@ interface AggregationResponsePart< // type MissingAggregationResponseTypes = Exclude< // AggregationType, -// keyof AggregationResponsePart<{}, unknown> +// keyof AggregationResponsePart<{}> // >; export type AggregationResponseMap< diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts index 8e49d02beb908..daf65e44980b6 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts @@ -15,7 +15,6 @@ import { Service } from './fields/service'; import { IStackframe } from './fields/stackframe'; import { Url } from './fields/url'; import { User } from './fields/user'; -import { Observer } from './fields/observer'; interface Processor { name: 'error'; @@ -62,5 +61,4 @@ export interface ErrorRaw extends APMBaseDoc { service: Service; url?: Url; user?: User; - observer?: Observer; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts deleted file mode 100644 index 42843130ec47f..0000000000000 --- a/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts +++ /dev/null @@ -1,10 +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. - */ - -export interface Observer { - version: string; - version_major: number; -} diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts index 4d5d2c5c4a12e..dbd9e7ede4256 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts @@ -6,7 +6,6 @@ import { APMBaseDoc } from './apm_base_doc'; import { IStackframe } from './fields/stackframe'; -import { Observer } from './fields/observer'; interface Processor { name: 'transaction'; @@ -51,5 +50,4 @@ export interface SpanRaw extends APMBaseDoc { transaction?: { id: string; }; - observer?: Observer; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts index b8ebb4cf8da51..3673f1f13c403 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts @@ -15,7 +15,6 @@ import { Service } from './fields/service'; import { Url } from './fields/url'; import { User } from './fields/user'; import { UserAgent } from './fields/user_agent'; -import { Observer } from './fields/observer'; interface Processor { name: 'transaction'; @@ -62,5 +61,4 @@ export interface TransactionRaw extends APMBaseDoc { url?: Url; user?: User; user_agent?: UserAgent; - observer?: Observer; } From 3e26777965a2efa56f7a2040619f016f34af5d0e Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 24 Mar 2020 07:27:15 +0100 Subject: [PATCH 055/179] Migrate doc view part of discover (#58094) --- .i18nrc.json | 1 + src/core/MIGRATION.md | 6 +- .../kibana/public/discover/build_services.ts | 14 ++- .../kibana/public/discover/kibana_services.ts | 1 - .../np_ready/angular/directives/field_name.js | 2 +- .../angular/{doc_viewer.ts => doc_viewer.tsx} | 8 +- .../discover/np_ready/components/_index.scss | 1 - .../np_ready/components/doc/doc.test.tsx | 8 +- .../discover/np_ready/components/doc/doc.tsx | 5 +- .../components/doc/use_es_doc_search.ts | 3 +- .../kibana/public/discover/plugin.ts | 46 ++------ .../new_platform/new_platform.karma_mock.js | 11 ++ .../ui/public/new_platform/new_platform.ts | 3 + src/plugins/discover/kibana.json | 6 + .../discover/public/components/_index.scss | 1 + .../__snapshots__/doc_viewer.test.tsx.snap | 0 .../doc_viewer_render_tab.test.tsx.snap | 0 .../components/doc_viewer/_doc_viewer.scss | 0 .../public}/components/doc_viewer/_index.scss | 0 .../components/doc_viewer/doc_viewer.test.tsx | 26 ++--- .../components/doc_viewer/doc_viewer.tsx | 4 +- .../doc_viewer/doc_viewer_render_error.tsx | 2 +- .../doc_viewer/doc_viewer_render_tab.test.tsx | 0 .../doc_viewer/doc_viewer_render_tab.tsx | 0 .../components/doc_viewer/doc_viewer_tab.tsx | 0 .../__snapshots__/field_name.test.tsx.snap | 0 .../field_name/field_name.test.tsx | 0 .../components}/field_name/field_name.tsx | 4 +- .../components}/field_name/field_type_name.ts | 24 ++-- .../json_code_block.test.tsx.snap | 0 .../json_code_block/json_code_block.test.tsx | 2 +- .../json_code_block/json_code_block.tsx | 2 +- .../public}/components/table/table.test.tsx | 5 +- .../public}/components/table/table.tsx | 0 .../components/table/table_helper.test.ts | 0 .../public}/components/table/table_helper.tsx | 0 .../public}/components/table/table_row.tsx | 2 +- .../table/table_row_btn_collapse.tsx | 2 +- .../table/table_row_btn_filter_add.tsx | 6 +- .../table/table_row_btn_filter_exists.tsx | 15 +-- .../table/table_row_btn_filter_remove.tsx | 6 +- .../table/table_row_btn_toggle_column.tsx | 20 ++-- .../table/table_row_icon_no_mapping.tsx | 11 +- .../table/table_row_icon_underscore.tsx | 4 +- .../public}/doc_views/doc_views_helpers.tsx | 0 .../public}/doc_views/doc_views_registry.ts | 12 +- .../public}/doc_views/doc_views_types.ts | 7 +- src/plugins/discover/public/helpers/index.ts | 20 ++++ .../public/helpers/shorten_dotted_string.ts | 26 +++++ src/plugins/discover/public/index.scss | 1 + src/plugins/discover/public/index.ts | 13 +++ src/plugins/discover/public/mocks.ts | 47 ++++++++ src/plugins/discover/public/plugin.ts | 110 ++++++++++++++++++ .../public/saved_searches/_saved_search.ts | 8 +- src/plugins/discover/public/services.ts | 25 ++++ test/plugin_functional/config.js | 1 + .../plugins/doc_views_plugin/kibana.json | 8 ++ .../plugins/doc_views_plugin/package.json | 17 +++ .../plugins/doc_views_plugin/public/index.ts | 22 ++++ .../doc_views_plugin/public/plugin.tsx | 60 ++++++++++ .../plugins/doc_views_plugin/tsconfig.json | 14 +++ .../test_suites/doc_views/doc_views.ts | 57 +++++++++ .../test_suites/doc_views/index.ts | 31 +++++ .../translations/translations/ja-JP.json | 22 ++-- .../translations/translations/zh-CN.json | 22 ++-- 65 files changed, 606 insertions(+), 168 deletions(-) rename src/legacy/core_plugins/kibana/public/discover/np_ready/angular/{doc_viewer.ts => doc_viewer.tsx} (87%) create mode 100644 src/plugins/discover/kibana.json create mode 100644 src/plugins/discover/public/components/_index.scss rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/_doc_viewer.scss (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/_index.scss (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer.test.tsx (81%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer.tsx (95%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer_render_error.tsx (94%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer_render_tab.test.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer_render_tab.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer_tab.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready/angular/directives => plugins/discover/public/components}/field_name/__snapshots__/field_name.test.tsx.snap (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready/angular/directives => plugins/discover/public/components}/field_name/field_name.test.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready/angular/directives => plugins/discover/public/components}/field_name/field_name.tsx (94%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready/angular/directives => plugins/discover/public/components}/field_name/field_type_name.ts (66%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/json_code_block/json_code_block.test.tsx (95%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/json_code_block/json_code_block.tsx (93%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table.test.tsx (98%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_helper.test.ts (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_helper.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row.tsx (98%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_btn_collapse.tsx (94%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_btn_filter_add.tsx (87%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_btn_filter_exists.tsx (80%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_btn_filter_remove.tsx (87%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_btn_toggle_column.tsx (79%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_icon_no_mapping.tsx (86%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_icon_underscore.tsx (89%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/doc_views/doc_views_helpers.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/doc_views/doc_views_registry.ts (82%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/doc_views/doc_views_types.ts (90%) create mode 100644 src/plugins/discover/public/helpers/index.ts create mode 100644 src/plugins/discover/public/helpers/shorten_dotted_string.ts create mode 100644 src/plugins/discover/public/index.scss create mode 100644 src/plugins/discover/public/mocks.ts create mode 100644 src/plugins/discover/public/plugin.ts create mode 100644 src/plugins/discover/public/services.ts create mode 100644 test/plugin_functional/plugins/doc_views_plugin/kibana.json create mode 100644 test/plugin_functional/plugins/doc_views_plugin/package.json create mode 100644 test/plugin_functional/plugins/doc_views_plugin/public/index.ts create mode 100644 test/plugin_functional/plugins/doc_views_plugin/public/plugin.tsx create mode 100644 test/plugin_functional/plugins/doc_views_plugin/tsconfig.json create mode 100644 test/plugin_functional/test_suites/doc_views/doc_views.ts create mode 100644 test/plugin_functional/test_suites/doc_views/index.ts diff --git a/.i18nrc.json b/.i18nrc.json index 36b28a0f5bd34..78c4be6f4a356 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -3,6 +3,7 @@ "common.ui": "src/legacy/ui", "console": "src/plugins/console", "core": "src/core", + "discover": "src/plugins/discover", "dashboard": "src/plugins/dashboard", "data": "src/plugins/data", "embeddableApi": "src/plugins/embeddable", diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md index 1ca9b63a51d18..0d5d300ec3b79 100644 --- a/src/core/MIGRATION.md +++ b/src/core/MIGRATION.md @@ -1233,11 +1233,11 @@ This table shows where these uiExports have moved to in the New Platform. In mos | `chromeNavControls` | [`core.chrome.navControls.register{Left,Right}`](/docs/development/core/public/kibana-plugin-public.chromenavcontrols.md) | | | `contextMenuActions` | | Should be an API on the devTools plugin. | | `devTools` | | | -| `docViews` | | | +| `docViews` | [`plugins.discover.docViews.addDocView`](./src/plugins/discover/public/doc_views) | Should be an API on the discover plugin. | | `embeddableActions` | | Should be an API on the embeddables plugin. | | `embeddableFactories` | | Should be an API on the embeddables plugin. | -| `fieldFormatEditors` | | | -| `fieldFormats` | [`plugins.data.fieldFormats`](./src/plugins/data/public/field_formats) | | +| `fieldFormatEditors` | | | +| `fieldFormats` | [`plugins.data.fieldFormats`](./src/plugins/data/public/field_formats) | | | `hacks` | n/a | Just run the code in your plugin's `start` method. | | `home` | [`plugins.home.featureCatalogue.register`](./src/plugins/home/public/feature_catalogue) | Must add `home` as a dependency in your kibana.json. | | `indexManagement` | | Should be an API on the indexManagement plugin. | diff --git a/src/legacy/core_plugins/kibana/public/discover/build_services.ts b/src/legacy/core_plugins/kibana/public/discover/build_services.ts index 282eef0c983eb..f881eb96e4e81 100644 --- a/src/legacy/core_plugins/kibana/public/discover/build_services.ts +++ b/src/legacy/core_plugins/kibana/public/discover/build_services.ts @@ -35,10 +35,13 @@ import { import { DiscoverStartPlugins } from './plugin'; import { SharePluginStart } from '../../../../../plugins/share/public'; -import { DocViewsRegistry } from './np_ready/doc_views/doc_views_registry'; import { ChartsPluginStart } from '../../../../../plugins/charts/public'; import { VisualizationsStart } from '../../../visualizations/public'; -import { createSavedSearchesLoader, SavedSearch } from '../../../../../plugins/discover/public'; +import { + createSavedSearchesLoader, + DocViewerComponent, + SavedSearch, +} from '../../../../../plugins/discover/public'; export interface DiscoverServices { addBasePath: (path: string) => string; @@ -47,7 +50,7 @@ export interface DiscoverServices { core: CoreStart; data: DataPublicPluginStart; docLinks: DocLinksStart; - docViewsRegistry: DocViewsRegistry; + DocViewer: DocViewerComponent; history: History; theme: ChartsPluginStart['theme']; filterManager: FilterManager; @@ -64,8 +67,7 @@ export interface DiscoverServices { } export async function buildServices( core: CoreStart, - plugins: DiscoverStartPlugins, - docViewsRegistry: DocViewsRegistry + plugins: DiscoverStartPlugins ): Promise { const services = { savedObjectsClient: core.savedObjects.client, @@ -81,7 +83,7 @@ export async function buildServices( core, data: plugins.data, docLinks: core.docLinks, - docViewsRegistry, + DocViewer: plugins.discover.docViews.DocViewer, history: createHashHistory(), theme: plugins.charts.theme, filterManager: plugins.data.query.filterManager, diff --git a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts index d369eb9679de6..7a3a6949baa94 100644 --- a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts @@ -75,7 +75,6 @@ export { EsQuerySortValue, SortDirection, } from '../../../../../plugins/data/public'; -export { ElasticSearchHit } from './np_ready/doc_views/doc_views_types'; export { getFormat } from 'ui/visualize/loader/pipeline_helpers/utilities'; // @ts-ignore export { buildPointSeriesData } from 'ui/agg_response/point_series/point_series'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name.js index b020113381992..47e50f3cc3d4b 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name.js @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { FieldName } from './field_name/field_name'; +import { FieldName } from '../../../../../../../../plugins/discover/public'; import { getServices, wrapInI18nContext } from '../../../kibana_services'; export function FieldNameDirectiveProvider(reactDirective) { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.tsx similarity index 87% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.ts rename to src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.tsx index 6ba47b839563b..90e061ac1aa05 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.tsx @@ -17,11 +17,15 @@ * under the License. */ -import { DocViewer } from '../components/doc_viewer/doc_viewer'; +import React from 'react'; +import { getServices } from '../../kibana_services'; export function createDocViewerDirective(reactDirective: any) { return reactDirective( - DocViewer, + (props: any) => { + const { DocViewer } = getServices(); + return ; + }, [ 'hit', ['indexPattern', { watchDepth: 'reference' }], diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss index 0491430e5fddd..7161560f8fda4 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss @@ -1,3 +1,2 @@ @import 'fetch_error/index'; @import 'field_chooser/index'; -@import 'doc_viewer/index'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx index 2278b243ecc14..1d19dc112d193 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx @@ -24,19 +24,13 @@ import { ReactWrapper } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; import { Doc, DocProps } from './doc'; -jest.mock('../doc_viewer/doc_viewer', () => ({ - DocViewer: () => null, -})); - jest.mock('../../../kibana_services', () => { return { getServices: () => ({ metadata: { branch: 'test', }, - getDocViewsSorted: () => { - return []; - }, + DocViewer: () => null, }), }; }); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx index 819eb9df592bd..28a17dbdb67b7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx @@ -20,9 +20,9 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiCallOut, EuiLink, EuiLoadingSpinner, EuiPageContent } from '@elastic/eui'; import { IndexPatternsContract } from 'src/plugins/data/public'; -import { DocViewer } from '../doc_viewer/doc_viewer'; import { ElasticRequestState, useEsDocSearch } from './use_es_doc_search'; -import { ElasticSearchHit, getServices } from '../../../kibana_services'; +import { getServices } from '../../../kibana_services'; +import { ElasticSearchHit } from '../../../../../../../../plugins/discover/public'; export interface ElasticSearchResult { hits: { @@ -61,6 +61,7 @@ export interface DocProps { } export function Doc(props: DocProps) { + const { DocViewer } = getServices(); const [reqState, hit, indexPattern] = useEsDocSearch(props); return ( diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts index 6cffc2cc533b0..2cd264578a596 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts @@ -17,8 +17,9 @@ * under the License. */ import { useEffect, useState } from 'react'; -import { ElasticSearchHit, IndexPattern } from '../../../kibana_services'; +import { IndexPattern } from '../../../kibana_services'; import { DocProps } from './doc'; +import { ElasticSearchHit } from '../../../../../../../../plugins/discover/public'; export enum ElasticRequestState { Loading, diff --git a/src/legacy/core_plugins/kibana/public/discover/plugin.ts b/src/legacy/core_plugins/kibana/public/discover/plugin.ts index ba671a64592a5..d3cdeb49fba71 100644 --- a/src/legacy/core_plugins/kibana/public/discover/plugin.ts +++ b/src/legacy/core_plugins/kibana/public/discover/plugin.ts @@ -19,7 +19,6 @@ import { BehaviorSubject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; -import { i18n } from '@kbn/i18n'; import { AppMountParameters, CoreSetup, CoreStart, Plugin } from 'kibana/public'; import angular, { auto } from 'angular'; import { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; @@ -41,10 +40,7 @@ import { KibanaLegacySetup, AngularRenderedAppUpdater, } from '../../../../../plugins/kibana_legacy/public'; -import { DocViewsRegistry } from './np_ready/doc_views/doc_views_registry'; -import { DocViewInput, DocViewInputFn } from './np_ready/doc_views/doc_views_types'; -import { DocViewTable } from './np_ready/components/table/table'; -import { JsonCodeBlock } from './np_ready/components/json_code_block/json_code_block'; +import { DiscoverSetup, DiscoverStart } from '../../../../../plugins/discover/public'; import { HomePublicPluginSetup } from '../../../../../plugins/home/public'; import { VisualizationsStart, @@ -52,15 +48,6 @@ import { } from '../../../visualizations/public/np_ready/public'; import { createKbnUrlTracker } from '../../../../../plugins/kibana_utils/public'; -/** - * These are the interfaces with your public contracts. You should export these - * for other plugins to use in _their_ `SetupDeps`/`StartDeps` interfaces. - * @public - */ -export interface DiscoverSetup { - addDocView(docViewRaw: DocViewInput | DocViewInputFn): void; -} -export type DiscoverStart = void; export interface DiscoverSetupPlugins { uiActions: UiActionsSetup; embeddable: EmbeddableSetup; @@ -68,6 +55,7 @@ export interface DiscoverSetupPlugins { home: HomePublicPluginSetup; visualizations: VisualizationsSetup; data: DataPublicPluginSetup; + discover: DiscoverSetup; } export interface DiscoverStartPlugins { uiActions: UiActionsStart; @@ -78,6 +66,7 @@ export interface DiscoverStartPlugins { share: SharePluginStart; inspector: any; visualizations: VisualizationsStart; + discover: DiscoverStart; } const innerAngularName = 'app/discover'; const embeddableAngularName = 'app/discoverEmbeddable'; @@ -87,10 +76,9 @@ const embeddableAngularName = 'app/discoverEmbeddable'; * There are 2 kinds of Angular bootstrapped for rendering, additionally to the main Angular * Discover provides embeddables, those contain a slimmer Angular */ -export class DiscoverPlugin implements Plugin { +export class DiscoverPlugin implements Plugin { private servicesInitialized: boolean = false; private innerAngularInitialized: boolean = false; - private docViewsRegistry: DocViewsRegistry | null = null; private embeddableInjector: auto.IInjectorService | null = null; private getEmbeddableInjector: (() => Promise) | null = null; private appStateUpdater = new BehaviorSubject(() => ({})); @@ -103,7 +91,7 @@ export class DiscoverPlugin implements Plugin { public initializeInnerAngular?: () => void; public initializeServices?: () => Promise<{ core: CoreStart; plugins: DiscoverStartPlugins }>; - setup(core: CoreSetup, plugins: DiscoverSetupPlugins): DiscoverSetup { + setup(core: CoreSetup, plugins: DiscoverSetupPlugins) { const { appMounted, appUnMounted, stop: stopUrlTracker } = createKbnUrlTracker({ baseUrl: core.http.basePath.prepend('/app/kibana'), defaultSubUrl: '#/discover', @@ -130,21 +118,7 @@ export class DiscoverPlugin implements Plugin { }; this.getEmbeddableInjector = this.getInjector.bind(this); - this.docViewsRegistry = new DocViewsRegistry(this.getEmbeddableInjector); - this.docViewsRegistry.addDocView({ - title: i18n.translate('kbn.discover.docViews.table.tableTitle', { - defaultMessage: 'Table', - }), - order: 10, - component: DocViewTable, - }); - this.docViewsRegistry.addDocView({ - title: i18n.translate('kbn.discover.docViews.json.jsonTitle', { - defaultMessage: 'JSON', - }), - order: 20, - component: JsonCodeBlock, - }); + plugins.discover.docViews.setAngularInjectorGetter(this.getEmbeddableInjector); plugins.kibanaLegacy.registerLegacyApp({ id: 'discover', title: 'Discover', @@ -172,14 +146,10 @@ export class DiscoverPlugin implements Plugin { }, }); registerFeature(plugins.home); - this.registerEmbeddable(core, plugins); - return { - addDocView: this.docViewsRegistry.addDocView.bind(this.docViewsRegistry), - }; } - start(core: CoreStart, plugins: DiscoverStartPlugins): DiscoverStart { + start(core: CoreStart, plugins: DiscoverStartPlugins) { // we need to register the application service at setup, but to render it // there are some start dependencies necessary, for this reason // initializeInnerAngular + initializeServices are assigned at start and used @@ -198,7 +168,7 @@ export class DiscoverPlugin implements Plugin { if (this.servicesInitialized) { return { core, plugins }; } - const services = await buildServices(core, plugins, this.docViewsRegistry!); + const services = await buildServices(core, plugins); setServices(services); this.servicesInitialized = true; diff --git a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js index c58a7d2fbb5cd..809022620e69d 100644 --- a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js +++ b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js @@ -271,6 +271,12 @@ export const npSetup = { }), }, }, + discover: { + docViews: { + addDocView: sinon.fake(), + setAngularInjectorGetter: sinon.fake(), + }, + }, visTypeVega: { config: sinon.fake(), }, @@ -459,6 +465,11 @@ export const npStart = { useChartsTheme: sinon.fake(), }, }, + discover: { + docViews: { + DocViewer: () => null, + }, + }, }, }; diff --git a/src/legacy/ui/public/new_platform/new_platform.ts b/src/legacy/ui/public/new_platform/new_platform.ts index deb8387fee29c..ee14f192a2149 100644 --- a/src/legacy/ui/public/new_platform/new_platform.ts +++ b/src/legacy/ui/public/new_platform/new_platform.ts @@ -65,6 +65,7 @@ import { NavigationPublicPluginStart, } from '../../../../plugins/navigation/public'; import { VisTypeVegaSetup } from '../../../../plugins/vis_type_vega/public'; +import { DiscoverSetup, DiscoverStart } from '../../../../plugins/discover/public'; export interface PluginsSetup { bfetch: BfetchPublicSetup; @@ -83,6 +84,7 @@ export interface PluginsSetup { advancedSettings: AdvancedSettingsSetup; management: ManagementSetup; visTypeVega: VisTypeVegaSetup; + discover: DiscoverSetup; telemetry?: TelemetryPluginSetup; } @@ -100,6 +102,7 @@ export interface PluginsStart { share: SharePluginStart; management: ManagementStart; advancedSettings: AdvancedSettingsStart; + discover: DiscoverStart; telemetry?: TelemetryPluginStart; } diff --git a/src/plugins/discover/kibana.json b/src/plugins/discover/kibana.json new file mode 100644 index 0000000000000..91d6358d44c18 --- /dev/null +++ b/src/plugins/discover/kibana.json @@ -0,0 +1,6 @@ +{ + "id": "discover", + "version": "kibana", + "server": false, + "ui": true +} diff --git a/src/plugins/discover/public/components/_index.scss b/src/plugins/discover/public/components/_index.scss new file mode 100644 index 0000000000000..ff50d4b5dca93 --- /dev/null +++ b/src/plugins/discover/public/components/_index.scss @@ -0,0 +1 @@ +@import 'doc_viewer/index'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap b/src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap rename to src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap b/src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap rename to src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/_doc_viewer.scss b/src/plugins/discover/public/components/doc_viewer/_doc_viewer.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/_doc_viewer.scss rename to src/plugins/discover/public/components/doc_viewer/_doc_viewer.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/_index.scss b/src/plugins/discover/public/components/doc_viewer/_index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/_index.scss rename to src/plugins/discover/public/components/doc_viewer/_index.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.test.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer.test.tsx similarity index 81% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.test.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer.test.tsx index 15f0f40700abc..6f29f10ddd026 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.test.tsx +++ b/src/plugins/discover/public/components/doc_viewer/doc_viewer.test.tsx @@ -21,37 +21,33 @@ import { mount, shallow } from 'enzyme'; import { DocViewer } from './doc_viewer'; // @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; -import { getServices } from '../../../kibana_services'; +import { getDocViewsRegistry } from '../../services'; import { DocViewRenderProps } from '../../doc_views/doc_views_types'; -jest.mock('../../../kibana_services', () => { +jest.mock('../../services', () => { let registry: any[] = []; return { - getServices: () => ({ - docViewsRegistry: { - addDocView(view: any) { - registry.push(view); - }, - getDocViewsSorted() { - return registry; - }, + getDocViewsRegistry: () => ({ + addDocView(view: any) { + registry.push(view); + }, + getDocViewsSorted() { + return registry; }, resetRegistry: () => { registry = []; }, }), - formatMsg: (x: any) => String(x), - formatStack: (x: any) => String(x), }; }); beforeEach(() => { - (getServices() as any).resetRegistry(); + (getDocViewsRegistry() as any).resetRegistry(); jest.clearAllMocks(); }); test('Render with 3 different tabs', () => { - const registry = getServices().docViewsRegistry; + const registry = getDocViewsRegistry(); registry.addDocView({ order: 10, title: 'Render function', render: jest.fn() }); registry.addDocView({ order: 20, title: 'React component', component: () =>
test
}); registry.addDocView({ order: 30, title: 'Invalid doc view' }); @@ -69,7 +65,7 @@ test('Render with 1 tab displaying error message', () => { return null; } - const registry = getServices().docViewsRegistry; + const registry = getDocViewsRegistry(); registry.addDocView({ order: 10, title: 'React component', diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer.tsx similarity index 95% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer.tsx index a177d8c29304c..792d9c44400d7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.tsx +++ b/src/plugins/discover/public/components/doc_viewer/doc_viewer.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; import { EuiTabbedContent } from '@elastic/eui'; -import { getServices } from '../../../kibana_services'; +import { getDocViewsRegistry } from '../../services'; import { DocViewerTab } from './doc_viewer_tab'; import { DocView, DocViewRenderProps } from '../../doc_views/doc_views_types'; @@ -29,7 +29,7 @@ import { DocView, DocViewRenderProps } from '../../doc_views/doc_views_types'; * a `render` function. */ export function DocViewer(renderProps: DocViewRenderProps) { - const { docViewsRegistry } = getServices(); + const docViewsRegistry = getDocViewsRegistry(); const tabs = docViewsRegistry .getDocViewsSorted(renderProps.hit) .map(({ title, render, component }: DocView, idx: number) => { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_error.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_error.tsx similarity index 94% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_error.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer_render_error.tsx index 075217add7b52..387e57dc8a7e3 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_error.tsx +++ b/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_error.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; import { EuiCallOut, EuiCodeBlock } from '@elastic/eui'; -import { formatMsg, formatStack } from '../../../kibana_services'; +import { formatMsg, formatStack } from '../../../../kibana_legacy/public'; interface Props { error: Error | string; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_tab.test.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.test.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_tab.test.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.test.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_tab.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_tab.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_tab.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer_tab.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_tab.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer_tab.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/__snapshots__/field_name.test.tsx.snap b/src/plugins/discover/public/components/field_name/__snapshots__/field_name.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/__snapshots__/field_name.test.tsx.snap rename to src/plugins/discover/public/components/field_name/__snapshots__/field_name.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.test.tsx b/src/plugins/discover/public/components/field_name/field_name.test.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.test.tsx rename to src/plugins/discover/public/components/field_name/field_name.test.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.tsx b/src/plugins/discover/public/components/field_name/field_name.tsx similarity index 94% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.tsx rename to src/plugins/discover/public/components/field_name/field_name.tsx index 1b3b16332fa4f..63518aae28de6 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.tsx +++ b/src/plugins/discover/public/components/field_name/field_name.tsx @@ -20,8 +20,8 @@ import React from 'react'; import classNames from 'classnames'; import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; -import { FieldIcon, FieldIconProps } from '../../../../../../../../../plugins/kibana_react/public'; -import { shortenDottedString } from '../../../helpers'; +import { FieldIcon, FieldIconProps } from '../../../../kibana_react/public'; +import { shortenDottedString } from '../../helpers'; import { getFieldTypeName } from './field_type_name'; // property field is provided at discover's field chooser diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_type_name.ts b/src/plugins/discover/public/components/field_name/field_type_name.ts similarity index 66% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_type_name.ts rename to src/plugins/discover/public/components/field_name/field_type_name.ts index 0cf428ee48b9d..a67c20fc4f353 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_type_name.ts +++ b/src/plugins/discover/public/components/field_name/field_type_name.ts @@ -21,52 +21,52 @@ import { i18n } from '@kbn/i18n'; export function getFieldTypeName(type: string) { switch (type) { case 'boolean': - return i18n.translate('kbn.discover.fieldNameIcons.booleanAriaLabel', { + return i18n.translate('discover.fieldNameIcons.booleanAriaLabel', { defaultMessage: 'Boolean field', }); case 'conflict': - return i18n.translate('kbn.discover.fieldNameIcons.conflictFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.conflictFieldAriaLabel', { defaultMessage: 'Conflicting field', }); case 'date': - return i18n.translate('kbn.discover.fieldNameIcons.dateFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.dateFieldAriaLabel', { defaultMessage: 'Date field', }); case 'geo_point': - return i18n.translate('kbn.discover.fieldNameIcons.geoPointFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.geoPointFieldAriaLabel', { defaultMessage: 'Geo point field', }); case 'geo_shape': - return i18n.translate('kbn.discover.fieldNameIcons.geoShapeFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.geoShapeFieldAriaLabel', { defaultMessage: 'Geo shape field', }); case 'ip': - return i18n.translate('kbn.discover.fieldNameIcons.ipAddressFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.ipAddressFieldAriaLabel', { defaultMessage: 'IP address field', }); case 'murmur3': - return i18n.translate('kbn.discover.fieldNameIcons.murmur3FieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.murmur3FieldAriaLabel', { defaultMessage: 'Murmur3 field', }); case 'number': - return i18n.translate('kbn.discover.fieldNameIcons.numberFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.numberFieldAriaLabel', { defaultMessage: 'Number field', }); case 'source': // Note that this type is currently not provided, type for _source is undefined - return i18n.translate('kbn.discover.fieldNameIcons.sourceFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.sourceFieldAriaLabel', { defaultMessage: 'Source field', }); case 'string': - return i18n.translate('kbn.discover.fieldNameIcons.stringFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.stringFieldAriaLabel', { defaultMessage: 'String field', }); case 'nested': - return i18n.translate('kbn.discover.fieldNameIcons.nestedFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.nestedFieldAriaLabel', { defaultMessage: 'Nested field', }); default: - return i18n.translate('kbn.discover.fieldNameIcons.unknownFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.unknownFieldAriaLabel', { defaultMessage: 'Unknown field', }); } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap b/src/plugins/discover/public/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap rename to src/plugins/discover/public/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.test.tsx b/src/plugins/discover/public/components/json_code_block/json_code_block.test.tsx similarity index 95% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.test.tsx rename to src/plugins/discover/public/components/json_code_block/json_code_block.test.tsx index 9cab7974c9eb2..7e7f80c6aaa56 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.test.tsx +++ b/src/plugins/discover/public/components/json_code_block/json_code_block.test.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { JsonCodeBlock } from './json_code_block'; -import { IndexPattern } from '../../../kibana_services'; +import { IndexPattern } from '../../../../data/public'; it('returns the `JsonCodeEditor` component', () => { const props = { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.tsx b/src/plugins/discover/public/components/json_code_block/json_code_block.tsx similarity index 93% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.tsx rename to src/plugins/discover/public/components/json_code_block/json_code_block.tsx index 3331969e351ab..9297ab0dfcf4d 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.tsx +++ b/src/plugins/discover/public/components/json_code_block/json_code_block.tsx @@ -22,7 +22,7 @@ import { i18n } from '@kbn/i18n'; import { DocViewRenderProps } from '../../doc_views/doc_views_types'; export function JsonCodeBlock({ hit }: DocViewRenderProps) { - const label = i18n.translate('kbn.discover.docViews.json.codeEditorAriaLabel', { + const label = i18n.translate('discover.docViews.json.codeEditorAriaLabel', { defaultMessage: 'Read only JSON view of an elasticsearch document', }); return ( diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.test.tsx b/src/plugins/discover/public/components/table/table.test.tsx similarity index 98% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.test.tsx rename to src/plugins/discover/public/components/table/table.test.tsx index 386f405544a61..91e116c4c6696 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.test.tsx +++ b/src/plugins/discover/public/components/table/table.test.tsx @@ -21,10 +21,7 @@ import { mount } from 'enzyme'; // @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import { DocViewTable } from './table'; - -import { IndexPattern, indexPatterns } from '../../../kibana_services'; - -jest.mock('ui/new_platform'); +import { indexPatterns, IndexPattern } from '../../../../data/public'; const indexPattern = { fields: [ diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.tsx b/src/plugins/discover/public/components/table/table.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.tsx rename to src/plugins/discover/public/components/table/table.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_helper.test.ts b/src/plugins/discover/public/components/table/table_helper.test.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_helper.test.ts rename to src/plugins/discover/public/components/table/table_helper.test.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_helper.tsx b/src/plugins/discover/public/components/table/table_helper.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_helper.tsx rename to src/plugins/discover/public/components/table/table_helper.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row.tsx b/src/plugins/discover/public/components/table/table_row.tsx similarity index 98% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row.tsx rename to src/plugins/discover/public/components/table/table_row.tsx index 5b13f6b3655c3..a4d5c57d10b33 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row.tsx +++ b/src/plugins/discover/public/components/table/table_row.tsx @@ -26,7 +26,7 @@ import { DocViewTableRowBtnCollapse } from './table_row_btn_collapse'; import { DocViewTableRowBtnFilterExists } from './table_row_btn_filter_exists'; import { DocViewTableRowIconNoMapping } from './table_row_icon_no_mapping'; import { DocViewTableRowIconUnderscore } from './table_row_icon_underscore'; -import { FieldName } from '../../angular/directives/field_name/field_name'; +import { FieldName } from '../field_name/field_name'; export interface Props { field: string; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_collapse.tsx b/src/plugins/discover/public/components/table/table_row_btn_collapse.tsx similarity index 94% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_collapse.tsx rename to src/plugins/discover/public/components/table/table_row_btn_collapse.tsx index e59f607329d4a..bb5ea4bd20f07 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_collapse.tsx +++ b/src/plugins/discover/public/components/table/table_row_btn_collapse.tsx @@ -26,7 +26,7 @@ export interface Props { } export function DocViewTableRowBtnCollapse({ onClick, isCollapsed }: Props) { - const label = i18n.translate('kbn.discover.docViews.table.toggleFieldDetails', { + const label = i18n.translate('discover.docViews.table.toggleFieldDetails', { defaultMessage: 'Toggle field details', }); return ( diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_filter_add.tsx b/src/plugins/discover/public/components/table/table_row_btn_filter_add.tsx similarity index 87% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_filter_add.tsx rename to src/plugins/discover/public/components/table/table_row_btn_filter_add.tsx index 8e2668e26cf08..bd842eb5c6f72 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_filter_add.tsx +++ b/src/plugins/discover/public/components/table/table_row_btn_filter_add.tsx @@ -29,12 +29,12 @@ export interface Props { export function DocViewTableRowBtnFilterAdd({ onClick, disabled = false }: Props) { const tooltipContent = disabled ? ( ) : ( ); @@ -42,7 +42,7 @@ export function DocViewTableRowBtnFilterAdd({ onClick, disabled = false }: Props return ( ) : ( ) ) : ( ); @@ -54,12 +54,9 @@ export function DocViewTableRowBtnFilterExists({ return ( ) : ( ); @@ -42,7 +42,7 @@ export function DocViewTableRowBtnFilterRemove({ onClick, disabled = false }: Pr return ( } > Index Patterns page', diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_icon_underscore.tsx b/src/plugins/discover/public/components/table/table_row_icon_underscore.tsx similarity index 89% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_icon_underscore.tsx rename to src/plugins/discover/public/components/table/table_row_icon_underscore.tsx index 724b5712cf1fe..791ab18de5175 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_icon_underscore.tsx +++ b/src/plugins/discover/public/components/table/table_row_icon_underscore.tsx @@ -22,13 +22,13 @@ import { i18n } from '@kbn/i18n'; export function DocViewTableRowIconUnderscore() { const ariaLabel = i18n.translate( - 'kbn.discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedAriaLabel', + 'discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedAriaLabel', { defaultMessage: 'Warning', } ); const tooltipContent = i18n.translate( - 'kbn.discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedTooltip', + 'discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedTooltip', { defaultMessage: 'Field names beginning with {underscoreSign} are not supported', values: { underscoreSign: '_' }, diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_helpers.tsx b/src/plugins/discover/public/doc_views/doc_views_helpers.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_helpers.tsx rename to src/plugins/discover/public/doc_views/doc_views_helpers.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_registry.ts b/src/plugins/discover/public/doc_views/doc_views_registry.ts similarity index 82% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_registry.ts rename to src/plugins/discover/public/doc_views/doc_views_registry.ts index 91acf1c7ac4ae..8f4518538be72 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_registry.ts +++ b/src/plugins/discover/public/doc_views/doc_views_registry.ts @@ -23,8 +23,11 @@ import { DocView, DocViewInput, ElasticSearchHit, DocViewInputFn } from './doc_v export class DocViewsRegistry { private docViews: DocView[] = []; + private angularInjectorGetter: (() => Promise) | null = null; - constructor(private getInjector: () => Promise) {} + setAngularInjectorGetter(injectorGetter: () => Promise) { + this.angularInjectorGetter = injectorGetter; + } /** * Extends and adds the given doc view to the registry array @@ -33,7 +36,12 @@ export class DocViewsRegistry { const docView = typeof docViewRaw === 'function' ? docViewRaw() : docViewRaw; if (docView.directive) { // convert angular directive to render function for backwards compatibility - docView.render = convertDirectiveToRenderFn(docView.directive, this.getInjector); + docView.render = convertDirectiveToRenderFn(docView.directive, () => { + if (!this.angularInjectorGetter) { + throw new Error('Angular was not initialized'); + } + return this.angularInjectorGetter(); + }); } if (typeof docView.shouldShow !== 'function') { docView.shouldShow = () => true; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_types.ts b/src/plugins/discover/public/doc_views/doc_views_types.ts similarity index 90% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_types.ts rename to src/plugins/discover/public/doc_views/doc_views_types.ts index a7828f9f0e7ed..0a4b5bb570bd7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_types.ts +++ b/src/plugins/discover/public/doc_views/doc_views_types.ts @@ -18,10 +18,10 @@ */ import { ComponentType } from 'react'; import { IScope } from 'angular'; -import { IndexPattern } from '../../kibana_services'; +import { IndexPattern } from '../../../data/public'; export interface AngularDirective { - controller: (scope: AngularScope) => void; + controller: (...injectedServices: any[]) => void; template: string; } @@ -51,13 +51,14 @@ export interface DocViewRenderProps { onAddColumn?: (columnName: string) => void; onRemoveColumn?: (columnName: string) => void; } +export type DocViewerComponent = ComponentType; export type DocViewRenderFn = ( domeNode: HTMLDivElement, renderProps: DocViewRenderProps ) => () => void; export interface DocViewInput { - component?: ComponentType; + component?: DocViewerComponent; directive?: AngularDirective; order: number; render?: DocViewRenderFn; diff --git a/src/plugins/discover/public/helpers/index.ts b/src/plugins/discover/public/helpers/index.ts new file mode 100644 index 0000000000000..7196c96989e97 --- /dev/null +++ b/src/plugins/discover/public/helpers/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { shortenDottedString } from './shorten_dotted_string'; diff --git a/src/plugins/discover/public/helpers/shorten_dotted_string.ts b/src/plugins/discover/public/helpers/shorten_dotted_string.ts new file mode 100644 index 0000000000000..9d78a96784339 --- /dev/null +++ b/src/plugins/discover/public/helpers/shorten_dotted_string.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ + +const DOT_PREFIX_RE = /(.).+?\./g; + +/** + * Convert a dot.notated.string into a short + * version (d.n.string) + */ +export const shortenDottedString = (input: string) => input.replace(DOT_PREFIX_RE, '$1.'); diff --git a/src/plugins/discover/public/index.scss b/src/plugins/discover/public/index.scss new file mode 100644 index 0000000000000..841415620d691 --- /dev/null +++ b/src/plugins/discover/public/index.scss @@ -0,0 +1 @@ +@import 'components/index'; diff --git a/src/plugins/discover/public/index.ts b/src/plugins/discover/public/index.ts index c5050147c3d5a..dbc361ee59f49 100644 --- a/src/plugins/discover/public/index.ts +++ b/src/plugins/discover/public/index.ts @@ -17,5 +17,18 @@ * under the License. */ +import { DiscoverPlugin } from './plugin'; + +export { DiscoverSetup, DiscoverStart } from './plugin'; +export { DocViewTable } from './components/table/table'; +export { JsonCodeBlock } from './components/json_code_block/json_code_block'; +export { DocViewInput, DocViewInputFn, DocViewerComponent } from './doc_views/doc_views_types'; +export { FieldName } from './components/field_name/field_name'; +export * from './doc_views/doc_views_types'; + +export function plugin() { + return new DiscoverPlugin(); +} + export { createSavedSearchesLoader } from './saved_searches/saved_searches'; export { SavedSearchLoader, SavedSearch } from './saved_searches/types'; diff --git a/src/plugins/discover/public/mocks.ts b/src/plugins/discover/public/mocks.ts new file mode 100644 index 0000000000000..bb05e3d412001 --- /dev/null +++ b/src/plugins/discover/public/mocks.ts @@ -0,0 +1,47 @@ +/* + * 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 { DiscoverSetup, DiscoverStart } from '.'; + +export type Setup = jest.Mocked; +export type Start = jest.Mocked; + +const createSetupContract = (): Setup => { + const setupContract: Setup = { + docViews: { + addDocView: jest.fn(), + setAngularInjectorGetter: jest.fn(), + }, + }; + return setupContract; +}; + +const createStartContract = (): Start => { + const startContract: Start = { + docViews: { + DocViewer: jest.fn(() => null), + }, + }; + return startContract; +}; + +export const discoverPluginMock = { + createSetupContract, + createStartContract, +}; diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts new file mode 100644 index 0000000000000..d2797586bfdfb --- /dev/null +++ b/src/plugins/discover/public/plugin.ts @@ -0,0 +1,110 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { auto } from 'angular'; +import { CoreSetup, Plugin } from 'kibana/public'; +import { DocViewInput, DocViewInputFn, DocViewRenderProps } from './doc_views/doc_views_types'; +import { DocViewsRegistry } from './doc_views/doc_views_registry'; +import { DocViewTable } from './components/table/table'; +import { JsonCodeBlock } from './components/json_code_block/json_code_block'; +import { DocViewer } from './components/doc_viewer/doc_viewer'; +import { setDocViewsRegistry } from './services'; + +import './index.scss'; + +/** + * @public + */ +export interface DiscoverSetup { + docViews: { + /** + * Add new doc view shown along with table view and json view in the details of each document in Discover. + * Both react and angular doc views are supported. + * @param docViewRaw + */ + addDocView(docViewRaw: DocViewInput | DocViewInputFn): void; + /** + * Set the angular injector for bootstrapping angular doc views. This is only exposed temporarily to aid + * migration to the new platform and will be removed soon. + * @deprecated + * @param injectorGetter + */ + setAngularInjectorGetter(injectorGetter: () => Promise): void; + }; +} +/** + * @public + */ +export interface DiscoverStart { + docViews: { + /** + * Component rendering all the doc views for a given document. + * This is only exposed temporarily to aid migration to the new platform and will be removed soon. + * @deprecated + */ + DocViewer: React.ComponentType; + }; +} + +/** + * Contains Discover, one of the oldest parts of Kibana + * There are 2 kinds of Angular bootstrapped for rendering, additionally to the main Angular + * Discover provides embeddables, those contain a slimmer Angular + */ +export class DiscoverPlugin implements Plugin { + private docViewsRegistry: DocViewsRegistry | null = null; + + setup(core: CoreSetup): DiscoverSetup { + this.docViewsRegistry = new DocViewsRegistry(); + setDocViewsRegistry(this.docViewsRegistry); + this.docViewsRegistry.addDocView({ + title: i18n.translate('discover.docViews.table.tableTitle', { + defaultMessage: 'Table', + }), + order: 10, + component: DocViewTable, + }); + this.docViewsRegistry.addDocView({ + title: i18n.translate('discover.docViews.json.jsonTitle', { + defaultMessage: 'JSON', + }), + order: 20, + component: JsonCodeBlock, + }); + + return { + docViews: { + addDocView: this.docViewsRegistry.addDocView.bind(this.docViewsRegistry), + setAngularInjectorGetter: this.docViewsRegistry.setAngularInjectorGetter.bind( + this.docViewsRegistry + ), + }, + }; + } + + start() { + return { + docViews: { + DocViewer, + }, + }; + } +} diff --git a/src/plugins/discover/public/saved_searches/_saved_search.ts b/src/plugins/discover/public/saved_searches/_saved_search.ts index 72983b7835eee..56360b04a49c8 100644 --- a/src/plugins/discover/public/saved_searches/_saved_search.ts +++ b/src/plugins/discover/public/saved_searches/_saved_search.ts @@ -16,7 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -import { createSavedObjectClass, SavedObjectKibanaServices } from '../../../saved_objects/public'; +import { + createSavedObjectClass, + SavedObject, + SavedObjectKibanaServices, +} from '../../../saved_objects/public'; export function createSavedSearchClass(services: SavedObjectKibanaServices) { const SavedObjectClass = createSavedObjectClass(services); @@ -66,5 +70,5 @@ export function createSavedSearchClass(services: SavedObjectKibanaServices) { } } - return SavedSearch; + return SavedSearch as new (id: string) => SavedObject; } diff --git a/src/plugins/discover/public/services.ts b/src/plugins/discover/public/services.ts new file mode 100644 index 0000000000000..3a28759d82b71 --- /dev/null +++ b/src/plugins/discover/public/services.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createGetterSetter } from '../../kibana_utils/common'; +import { DocViewsRegistry } from './doc_views/doc_views_registry'; + +export const [getDocViewsRegistry, setDocViewsRegistry] = createGetterSetter( + 'DocViewsRegistry' +); diff --git a/test/plugin_functional/config.js b/test/plugin_functional/config.js index e63054f1b6912..7017c01cc5634 100644 --- a/test/plugin_functional/config.js +++ b/test/plugin_functional/config.js @@ -39,6 +39,7 @@ export default async function({ readConfigFile }) { require.resolve('./test_suites/core_plugins'), require.resolve('./test_suites/management'), require.resolve('./test_suites/bfetch_explorer'), + require.resolve('./test_suites/doc_views'), ], services: { ...functionalConfig.get('services'), diff --git a/test/plugin_functional/plugins/doc_views_plugin/kibana.json b/test/plugin_functional/plugins/doc_views_plugin/kibana.json new file mode 100644 index 0000000000000..f8596aad01e87 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "docViewPlugin", + "version": "0.0.1", + "kibanaVersion": "kibana", + "server": false, + "ui": true, + "requiredPlugins": ["discover"] +} diff --git a/test/plugin_functional/plugins/doc_views_plugin/package.json b/test/plugin_functional/plugins/doc_views_plugin/package.json new file mode 100644 index 0000000000000..0cef1bf65c0e8 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/package.json @@ -0,0 +1,17 @@ +{ + "name": "docViewPlugin", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/doc_views_plugin", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.7.2" + } +} diff --git a/test/plugin_functional/plugins/doc_views_plugin/public/index.ts b/test/plugin_functional/plugins/doc_views_plugin/public/index.ts new file mode 100644 index 0000000000000..8097226180763 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/public/index.ts @@ -0,0 +1,22 @@ +/* + * 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 { DocViewsPlugin } from './plugin'; + +export const plugin = () => new DocViewsPlugin(); diff --git a/test/plugin_functional/plugins/doc_views_plugin/public/plugin.tsx b/test/plugin_functional/plugins/doc_views_plugin/public/plugin.tsx new file mode 100644 index 0000000000000..4b9823fda3673 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/public/plugin.tsx @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import angular from 'angular'; +import React from 'react'; +import { Plugin, CoreSetup } from 'kibana/public'; +import { DiscoverSetup } from '../../../../../src/plugins/discover/public'; + +angular.module('myDocView', []).directive('myHit', () => ({ + restrict: 'E', + scope: { + hit: '=hit', + }, + template: '

{{hit._index}}

', +})); + +function MyHit(props: { index: string }) { + return

{props.index}

; +} + +export class DocViewsPlugin implements Plugin { + public setup(core: CoreSetup, { discover }: { discover: DiscoverSetup }) { + discover.docViews.addDocView({ + directive: { + controller: function MyController($injector: any) { + $injector.loadNewModules(['myDocView']); + }, + template: ``, + }, + order: 1, + title: 'Angular doc view', + }); + + discover.docViews.addDocView({ + component: props => { + return ; + }, + order: 2, + title: 'React doc view', + }); + } + + public start() {} +} diff --git a/test/plugin_functional/plugins/doc_views_plugin/tsconfig.json b/test/plugin_functional/plugins/doc_views_plugin/tsconfig.json new file mode 100644 index 0000000000000..4a564ee1e5578 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../../../typings/**/*" + ], + "exclude": [] +} diff --git a/test/plugin_functional/test_suites/doc_views/doc_views.ts b/test/plugin_functional/test_suites/doc_views/doc_views.ts new file mode 100644 index 0000000000000..8764f45c2c076 --- /dev/null +++ b/test/plugin_functional/test_suites/doc_views/doc_views.ts @@ -0,0 +1,57 @@ +/* + * 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 expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); + + describe('custom doc views', function() { + before(async () => { + await PageObjects.common.navigateToApp('discover'); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + }); + + it('should show custom doc views', async () => { + await testSubjects.click('docTableExpandToggleColumn'); + const angularTab = await find.byButtonText('Angular doc view'); + const reactTab = await find.byButtonText('React doc view'); + expect(await angularTab.isDisplayed()).to.be(true); + expect(await reactTab.isDisplayed()).to.be(true); + }); + + it('should render angular doc view', async () => { + const angularTab = await find.byButtonText('Angular doc view'); + await angularTab.click(); + const angularContent = await testSubjects.find('angular-docview'); + expect(await angularContent.getVisibleText()).to.be('logstash-2015.09.22'); + }); + + it('should render react doc view', async () => { + const reactTab = await find.byButtonText('React doc view'); + await reactTab.click(); + const reactContent = await testSubjects.find('react-docview'); + expect(await reactContent.getVisibleText()).to.be('logstash-2015.09.22'); + }); + }); +} diff --git a/test/plugin_functional/test_suites/doc_views/index.ts b/test/plugin_functional/test_suites/doc_views/index.ts new file mode 100644 index 0000000000000..dee3a72e3f2c6 --- /dev/null +++ b/test/plugin_functional/test_suites/doc_views/index.ts @@ -0,0 +1,31 @@ +/* + * 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 { PluginFunctionalProviderContext } from '../../services'; + +export default function({ getService, loadTestFile }: PluginFunctionalProviderContext) { + const esArchiver = getService('esArchiver'); + + describe('doc views', function() { + before(async () => { + await esArchiver.loadIfNeeded('../functional/fixtures/es_archiver/discover'); + }); + + loadTestFile(require.resolve('./doc_views')); + }); +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e8d93ba6d3200..fe7ad863945c5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -464,6 +464,17 @@ "data.search.searchBar.savedQueryPopoverSavedQueryListItemDescriptionAriaLabel": "{savedQueryName} の説明", "data.search.searchBar.savedQueryPopoverSavedQueryListItemSelectedButtonAriaLabel": "選択されたクエリボタン {savedQueryName} を保存しました。変更を破棄するには押してください。", "data.search.searchBar.savedQueryPopoverTitleText": "保存されたクエリ", + "discover.fieldNameIcons.booleanAriaLabel": "ブールフィールド", + "discover.fieldNameIcons.conflictFieldAriaLabel": "矛盾フィールド", + "discover.fieldNameIcons.dateFieldAriaLabel": "日付フィールド", + "discover.fieldNameIcons.geoPointFieldAriaLabel": "地理ポイント", + "discover.fieldNameIcons.geoShapeFieldAriaLabel": "地理情報図形", + "discover.fieldNameIcons.ipAddressFieldAriaLabel": "IP アドレスフィールド", + "discover.fieldNameIcons.murmur3FieldAriaLabel": "Murmur3 フィールド", + "discover.fieldNameIcons.numberFieldAriaLabel": "数値フィールド", + "discover.fieldNameIcons.sourceFieldAriaLabel": "ソースフィールド", + "discover.fieldNameIcons.stringFieldAriaLabel": "文字列フィールド", + "discover.fieldNameIcons.unknownFieldAriaLabel": "不明なフィールド", "charts.colormaps.bluesText": "青", "charts.colormaps.greensText": "緑", "charts.colormaps.greenToRedText": "緑から赤", @@ -1075,17 +1086,6 @@ "kbn.discover.fieldChooser.searchPlaceHolder": "検索フィールド", "kbn.discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel": "フィールド設定を非表示", "kbn.discover.fieldChooser.toggleFieldFilterButtonShowAriaLabel": "フィールド設定を表示", - "kbn.discover.fieldNameIcons.booleanAriaLabel": "ブールフィールド", - "kbn.discover.fieldNameIcons.conflictFieldAriaLabel": "矛盾フィールド", - "kbn.discover.fieldNameIcons.dateFieldAriaLabel": "日付フィールド", - "kbn.discover.fieldNameIcons.geoPointFieldAriaLabel": "地理ポイント", - "kbn.discover.fieldNameIcons.geoShapeFieldAriaLabel": "地理情報図形", - "kbn.discover.fieldNameIcons.ipAddressFieldAriaLabel": "IP アドレスフィールド", - "kbn.discover.fieldNameIcons.murmur3FieldAriaLabel": "Murmur3 フィールド", - "kbn.discover.fieldNameIcons.numberFieldAriaLabel": "数値フィールド", - "kbn.discover.fieldNameIcons.sourceFieldAriaLabel": "ソースフィールド", - "kbn.discover.fieldNameIcons.stringFieldAriaLabel": "文字列フィールド", - "kbn.discover.fieldNameIcons.unknownFieldAriaLabel": "不明なフィールド", "kbn.discover.histogram.partialData.bucketTooltipText": "選択された時間範囲にはこのバケット全体は含まれていませんが、一部データが含まれている可能性があります。", "kbn.discover.histogramOfFoundDocumentsAriaLabel": "発見されたドキュメントのヒストグラム", "kbn.discover.hitsPluralTitle": "{hits, plural, one {ヒット} other {ヒット}}", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index cfab424935c6d..e1cfa5e4ef358 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -464,6 +464,17 @@ "data.search.searchBar.savedQueryPopoverSavedQueryListItemDescriptionAriaLabel": "{savedQueryName} 描述", "data.search.searchBar.savedQueryPopoverSavedQueryListItemSelectedButtonAriaLabel": "已保存查询按钮已选择 {savedQueryName}。按下可清除任何更改。", "data.search.searchBar.savedQueryPopoverTitleText": "已保存查询", + "discover.fieldNameIcons.booleanAriaLabel": "布尔字段", + "discover.fieldNameIcons.conflictFieldAriaLabel": "冲突字段", + "discover.fieldNameIcons.dateFieldAriaLabel": "日期字段", + "discover.fieldNameIcons.geoPointFieldAriaLabel": "地理位置点字段", + "discover.fieldNameIcons.geoShapeFieldAriaLabel": "几何形状字段", + "discover.fieldNameIcons.ipAddressFieldAriaLabel": "IP 地址字段", + "discover.fieldNameIcons.murmur3FieldAriaLabel": "Murmur3 字段", + "discover.fieldNameIcons.numberFieldAriaLabel": "数字字段", + "discover.fieldNameIcons.sourceFieldAriaLabel": "源字段", + "discover.fieldNameIcons.stringFieldAriaLabel": "字符串字段", + "discover.fieldNameIcons.unknownFieldAriaLabel": "未知字段", "charts.colormaps.bluesText": "蓝色", "charts.colormaps.greensText": "绿色", "charts.colormaps.greenToRedText": "绿到红", @@ -1075,17 +1086,6 @@ "kbn.discover.fieldChooser.searchPlaceHolder": "搜索字段", "kbn.discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel": "隐藏字段设置", "kbn.discover.fieldChooser.toggleFieldFilterButtonShowAriaLabel": "显示字段设置", - "kbn.discover.fieldNameIcons.booleanAriaLabel": "布尔字段", - "kbn.discover.fieldNameIcons.conflictFieldAriaLabel": "冲突字段", - "kbn.discover.fieldNameIcons.dateFieldAriaLabel": "日期字段", - "kbn.discover.fieldNameIcons.geoPointFieldAriaLabel": "地理位置点字段", - "kbn.discover.fieldNameIcons.geoShapeFieldAriaLabel": "几何形状字段", - "kbn.discover.fieldNameIcons.ipAddressFieldAriaLabel": "IP 地址字段", - "kbn.discover.fieldNameIcons.murmur3FieldAriaLabel": "Murmur3 字段", - "kbn.discover.fieldNameIcons.numberFieldAriaLabel": "数字字段", - "kbn.discover.fieldNameIcons.sourceFieldAriaLabel": "源字段", - "kbn.discover.fieldNameIcons.stringFieldAriaLabel": "字符串字段", - "kbn.discover.fieldNameIcons.unknownFieldAriaLabel": "未知字段", "kbn.discover.histogram.partialData.bucketTooltipText": "选定的时间范围不包括此整个存储桶,其可能包含部分数据。", "kbn.discover.histogramOfFoundDocumentsAriaLabel": "已找到文档的直方图", "kbn.discover.hitsPluralTitle": "{hits, plural, one {次命中} other {次命中}}", From e6dbc3fc21c89dc82ddf69221695543e4bfa0a4d Mon Sep 17 00:00:00 2001 From: Tudor Golubenco Date: Tue, 24 Mar 2020 08:10:10 +0100 Subject: [PATCH 056/179] [SIEM] Updates process and TLS tables to use ECS 1.5 fields (#60854) * Added new process filter * Use new ECS TLS fields --- .../__snapshots__/index.test.tsx.snap | 35 +- .../page/network/tls_table/columns.tsx | 26 +- .../components/page/network/tls_table/mock.ts | 15 +- .../page/network/tls_table/translations.ts | 2 +- .../public/containers/tls/index.gql_query.ts | 5 +- .../siem/public/graphql/introspection.json | 20 +- .../plugins/siem/public/graphql/types.ts | 12 +- .../siem/server/graphql/tls/schema.gql.ts | 5 +- .../plugins/siem/server/graphql/types.ts | 21 +- .../server/lib/tls/elasticsearch_adapter.ts | 5 +- .../plugins/siem/server/lib/tls/mock.ts | 274 +- .../siem/server/lib/tls/query_tls.dsl.ts | 21 +- .../plugins/siem/server/lib/tls/types.ts | 8 +- .../lib/uncommon_processes/query.dsl.ts | 16 + x-pack/test/api_integration/apis/siem/tls.ts | 68 +- .../es_archives/packetbeat/tls/data.json.gz | Bin 0 -> 3929 bytes .../es_archives/packetbeat/tls/mappings.json | 9583 +++++++++++++++++ 17 files changed, 9739 insertions(+), 377 deletions(-) create mode 100644 x-pack/test/functional/es_archives/packetbeat/tls/data.json.gz create mode 100644 x-pack/test/functional/es_archives/packetbeat/tls/mappings.json diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/__snapshots__/index.test.tsx.snap index 85b028cf7cd51..8b7d8efa7ac37 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/__snapshots__/index.test.tsx.snap @@ -10,14 +10,7 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] }, "node": Object { "_id": "2fe3bdf168af35b9e0ce5dc583bab007c40d47de", - "alternativeNames": Array [ - "*.elastic.co", - "elastic.co", - ], - "commonNames": Array [ - "*.elastic.co", - ], - "issuerNames": Array [ + "issuers": Array [ "DigiCert SHA2 Secure Server CA", ], "ja3": Array [ @@ -27,6 +20,9 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] "notAfter": Array [ "2021-04-22T12:00:00.000Z", ], + "subjects": Array [ + "*.elastic.co", + ], }, }, Object { @@ -35,13 +31,7 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] }, "node": Object { "_id": "61749734b3246f1584029deb4f5276c64da00ada", - "alternativeNames": Array [ - "api.snapcraft.io", - ], - "commonNames": Array [ - "api.snapcraft.io", - ], - "issuerNames": Array [ + "issuers": Array [ "DigiCert SHA2 Secure Server CA", ], "ja3": Array [ @@ -50,6 +40,9 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] "notAfter": Array [ "2019-05-22T12:00:00.000Z", ], + "subjects": Array [ + "api.snapcraft.io", + ], }, }, Object { @@ -58,14 +51,7 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] }, "node": Object { "_id": "6560d3b7dd001c989b85962fa64beb778cdae47a", - "alternativeNames": Array [ - "changelogs.ubuntu.com", - "manpages.ubuntu.com", - ], - "commonNames": Array [ - "changelogs.ubuntu.com", - ], - "issuerNames": Array [ + "issuers": Array [ "Let's Encrypt Authority X3", ], "ja3": Array [ @@ -74,6 +60,9 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] "notAfter": Array [ "2019-06-27T01:09:59.000Z", ], + "subjects": Array [ + "changelogs.ubuntu.com", + ], }, }, ] diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx index 44a538871d951..f95475819abc9 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx @@ -32,11 +32,11 @@ export const getTlsColumns = (tableId: string): TlsColumns => [ truncateText: false, hideForMobile: false, sortable: false, - render: ({ _id, issuerNames }) => + render: ({ _id, issuers }) => getRowItemDraggables({ - rowItems: issuerNames, - attrName: 'tls.server_certificate.issuer.common_name', - idPrefix: `${tableId}-${_id}-table-issuerNames`, + rowItems: issuers, + attrName: 'tls.server.issuer', + idPrefix: `${tableId}-${_id}-table-issuers`, }), }, { @@ -45,18 +45,12 @@ export const getTlsColumns = (tableId: string): TlsColumns => [ truncateText: false, hideForMobile: false, sortable: false, - render: ({ _id, alternativeNames, commonNames }) => - alternativeNames != null && alternativeNames.length > 0 - ? getRowItemDraggables({ - rowItems: alternativeNames, - attrName: 'tls.server_certificate.alternative_names', - idPrefix: `${tableId}-${_id}-table-alternative-name`, - }) - : getRowItemDraggables({ - rowItems: commonNames, - attrName: 'tls.server_certificate.subject.common_name', - idPrefix: `${tableId}-${_id}-table-common-name`, - }), + render: ({ _id, subjects }) => + getRowItemDraggables({ + rowItems: subjects, + attrName: 'tls.server.subject', + idPrefix: `${tableId}-${_id}-table-subjects`, + }), }, { field: 'node._id', diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/mock.ts b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/mock.ts index 77148bf50c038..453bd8fc84dfa 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/mock.ts +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/mock.ts @@ -12,10 +12,9 @@ export const mockTlsData: TlsData = { { node: { _id: '2fe3bdf168af35b9e0ce5dc583bab007c40d47de', - alternativeNames: ['*.elastic.co', 'elastic.co'], - commonNames: ['*.elastic.co'], + subjects: ['*.elastic.co'], ja3: ['7851693188210d3b271aa1713d8c68c2', 'fb4726d465c5f28b84cd6d14cedd13a7'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], + issuers: ['DigiCert SHA2 Secure Server CA'], notAfter: ['2021-04-22T12:00:00.000Z'], }, cursor: { @@ -25,10 +24,9 @@ export const mockTlsData: TlsData = { { node: { _id: '61749734b3246f1584029deb4f5276c64da00ada', - alternativeNames: ['api.snapcraft.io'], - commonNames: ['api.snapcraft.io'], + subjects: ['api.snapcraft.io'], ja3: ['839868ad711dc55bde0d37a87f14740d'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], + issuers: ['DigiCert SHA2 Secure Server CA'], notAfter: ['2019-05-22T12:00:00.000Z'], }, cursor: { @@ -38,10 +36,9 @@ export const mockTlsData: TlsData = { { node: { _id: '6560d3b7dd001c989b85962fa64beb778cdae47a', - alternativeNames: ['changelogs.ubuntu.com', 'manpages.ubuntu.com'], - commonNames: ['changelogs.ubuntu.com'], + subjects: ['changelogs.ubuntu.com'], ja3: ['da12c94da8021bbaf502907ad086e7bc'], - issuerNames: ["Let's Encrypt Authority X3"], + issuers: ["Let's Encrypt Authority X3"], notAfter: ['2019-06-27T01:09:59.000Z'], }, cursor: { diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts index 89d0f58684cbe..ff714204144ec 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts @@ -16,7 +16,7 @@ export const TRANSPORT_LAYER_SECURITY = i18n.translate( export const UNIT = (totalCount: number) => i18n.translate('xpack.siem.network.ipDetails.tlsTable.unit', { values: { totalCount }, - defaultMessage: `{totalCount, plural, =1 {issuer} other {issuers}}`, + defaultMessage: `{totalCount, plural, =1 {server certificate} other {server certificates}}`, }); // Columns diff --git a/x-pack/legacy/plugins/siem/public/containers/tls/index.gql_query.ts b/x-pack/legacy/plugins/siem/public/containers/tls/index.gql_query.ts index bbb92282bee83..f513a94d69667 100644 --- a/x-pack/legacy/plugins/siem/public/containers/tls/index.gql_query.ts +++ b/x-pack/legacy/plugins/siem/public/containers/tls/index.gql_query.ts @@ -33,10 +33,9 @@ export const tlsQuery = gql` edges { node { _id - alternativeNames - commonNames + subjects ja3 - issuerNames + issuers notAfter } cursor { diff --git a/x-pack/legacy/plugins/siem/public/graphql/introspection.json b/x-pack/legacy/plugins/siem/public/graphql/introspection.json index 9802a5f5bd3bf..5d43024625d0d 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/introspection.json +++ b/x-pack/legacy/plugins/siem/public/graphql/introspection.json @@ -9213,22 +9213,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "alternativeNames", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "notAfter", "description": "", @@ -9246,7 +9230,7 @@ "deprecationReason": null }, { - "name": "commonNames", + "name": "subjects", "description": "", "args": [], "type": { @@ -9278,7 +9262,7 @@ "deprecationReason": null }, { - "name": "issuerNames", + "name": "issuers", "description": "", "args": [], "type": { diff --git a/x-pack/legacy/plugins/siem/public/graphql/types.ts b/x-pack/legacy/plugins/siem/public/graphql/types.ts index 3528ee6e13a38..a5d1e3fbcba27 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/public/graphql/types.ts @@ -1859,15 +1859,13 @@ export interface TlsNode { timestamp?: Maybe; - alternativeNames?: Maybe; - notAfter?: Maybe; - commonNames?: Maybe; + subjects?: Maybe; ja3?: Maybe; - issuerNames?: Maybe; + issuers?: Maybe; } export interface UncommonProcessesData { @@ -5679,13 +5677,11 @@ export namespace GetTlsQuery { _id: Maybe; - alternativeNames: Maybe; - - commonNames: Maybe; + subjects: Maybe; ja3: Maybe; - issuerNames: Maybe; + issuers: Maybe; notAfter: Maybe; }; diff --git a/x-pack/legacy/plugins/siem/server/graphql/tls/schema.gql.ts b/x-pack/legacy/plugins/siem/server/graphql/tls/schema.gql.ts index 301960cea33ef..452c615c65aa5 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/tls/schema.gql.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/tls/schema.gql.ts @@ -13,11 +13,10 @@ export const tlsSchema = gql` type TlsNode { _id: String timestamp: Date - alternativeNames: [String!] notAfter: [String!] - commonNames: [String!] + subjects: [String!] ja3: [String!] - issuerNames: [String!] + issuers: [String!] } input TlsSortField { field: TlsFields! diff --git a/x-pack/legacy/plugins/siem/server/graphql/types.ts b/x-pack/legacy/plugins/siem/server/graphql/types.ts index f42da48f2c1da..e2b365f8bfa5b 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/types.ts @@ -1861,15 +1861,13 @@ export interface TlsNode { timestamp?: Maybe; - alternativeNames?: Maybe; - notAfter?: Maybe; - commonNames?: Maybe; + subjects?: Maybe; ja3?: Maybe; - issuerNames?: Maybe; + issuers?: Maybe; } export interface UncommonProcessesData { @@ -7824,15 +7822,13 @@ export namespace TlsNodeResolvers { timestamp?: TimestampResolver, TypeParent, TContext>; - alternativeNames?: AlternativeNamesResolver, TypeParent, TContext>; - notAfter?: NotAfterResolver, TypeParent, TContext>; - commonNames?: CommonNamesResolver, TypeParent, TContext>; + subjects?: SubjectsResolver, TypeParent, TContext>; ja3?: Ja3Resolver, TypeParent, TContext>; - issuerNames?: IssuerNamesResolver, TypeParent, TContext>; + issuers?: IssuersResolver, TypeParent, TContext>; } export type _IdResolver, Parent = TlsNode, TContext = SiemContext> = Resolver< @@ -7845,17 +7841,12 @@ export namespace TlsNodeResolvers { Parent = TlsNode, TContext = SiemContext > = Resolver; - export type AlternativeNamesResolver< - R = Maybe, - Parent = TlsNode, - TContext = SiemContext - > = Resolver; export type NotAfterResolver< R = Maybe, Parent = TlsNode, TContext = SiemContext > = Resolver; - export type CommonNamesResolver< + export type SubjectsResolver< R = Maybe, Parent = TlsNode, TContext = SiemContext @@ -7865,7 +7856,7 @@ export namespace TlsNodeResolvers { Parent, TContext >; - export type IssuerNamesResolver< + export type IssuersResolver< R = Maybe, Parent = TlsNode, TContext = SiemContext diff --git a/x-pack/legacy/plugins/siem/server/lib/tls/elasticsearch_adapter.ts b/x-pack/legacy/plugins/siem/server/lib/tls/elasticsearch_adapter.ts index 716eea3f8df5b..10929c3d03641 100644 --- a/x-pack/legacy/plugins/siem/server/lib/tls/elasticsearch_adapter.ts +++ b/x-pack/legacy/plugins/siem/server/lib/tls/elasticsearch_adapter.ts @@ -66,10 +66,9 @@ export const formatTlsEdges = (buckets: TlsBuckets[]): TlsEdges[] => { const edge: TlsEdges = { node: { _id: bucket.key, - alternativeNames: bucket.alternative_names.buckets.map(({ key }) => key), - commonNames: bucket.common_names.buckets.map(({ key }) => key), + subjects: bucket.subjects.buckets.map(({ key }) => key), ja3: bucket.ja3.buckets.map(({ key }) => key), - issuerNames: bucket.issuer_names.buckets.map(({ key }) => key), + issuers: bucket.issuers.buckets.map(({ key }) => key), // eslint-disable-next-line @typescript-eslint/camelcase notAfter: bucket.not_after.buckets.map(({ key_as_string }) => key_as_string), }, diff --git a/x-pack/legacy/plugins/siem/server/lib/tls/mock.ts b/x-pack/legacy/plugins/siem/server/lib/tls/mock.ts index 4b27d541ec992..b97a6fa509ef2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/tls/mock.ts +++ b/x-pack/legacy/plugins/siem/server/lib/tls/mock.ts @@ -20,11 +20,10 @@ export const mockTlsQuery = { order: { _key: 'desc' }, }, aggs: { - issuer_names: { terms: { field: 'tls.server_certificate.issuer.common_name' } }, - common_names: { terms: { field: 'tls.server_certificate.subject.common_name' } }, - alternative_names: { terms: { field: 'tls.server_certificate.alternative_names' } }, - not_after: { terms: { field: 'tls.server_certificate.not_after' } }, - ja3: { terms: { field: 'tls.fingerprints.ja3.hash' } }, + issuers: { terms: { field: 'tls.server.issuer' } }, + subjects: { terms: { field: 'tls.server.subject' } }, + not_after: { terms: { field: 'tls.server.not_after' } }, + ja3: { terms: { field: 'tls.server.ja3s' } }, }, }, }, @@ -44,16 +43,8 @@ export const expectedTlsEdges = [ }, node: { _id: 'fff8dc95436e0e25ce46b1526a1a547e8cf3bb82', - alternativeNames: [ - '*.1.nflxso.net', - '*.a.nflxso.net', - 'assets.nflxext.com', - 'cast.netflix.com', - 'codex.nflxext.com', - 'tvui.netflix.com', - ], - commonNames: ['*.1.nflxso.net'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], + subjects: ['*.1.nflxso.net'], + issuers: ['DigiCert SHA2 Secure Server CA'], ja3: ['95d2dd53a89b334cddd5c22e81e7fe61'], notAfter: ['2019-10-27T12:00:00.000Z'], }, @@ -65,9 +56,8 @@ export const expectedTlsEdges = [ }, node: { _id: 'fd8440c4b20978b173e0910e2639d114f0d405c5', - alternativeNames: ['*.cogocast.net', 'cogocast.net'], - commonNames: ['cogocast.net'], - issuerNames: ['Amazon'], + subjects: ['cogocast.net'], + issuers: ['Amazon'], ja3: ['a111d93cdf31f993c40a8a9ef13e8d7e'], notAfter: ['2020-02-01T12:00:00.000Z'], }, @@ -76,12 +66,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fcdc16645ebb3386adc96e7ba735c4745709b9dd' }, node: { _id: 'fcdc16645ebb3386adc96e7ba735c4745709b9dd', - alternativeNames: [ - 'player-devintever2-imperva.mountain.siriusxm.com', - 'player-devintever2.mountain.siriusxm.com', - ], - commonNames: ['player-devintever2.mountain.siriusxm.com'], - issuerNames: ['Trustwave Organization Validation SHA256 CA, Level 1'], + subjects: ['player-devintever2.mountain.siriusxm.com'], + issuers: ['Trustwave Organization Validation SHA256 CA, Level 1'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2020-03-06T21:57:09.000Z'], }, @@ -90,15 +76,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fccf375789cb7e671502a7b0cc969f218a4b2c70' }, node: { _id: 'fccf375789cb7e671502a7b0cc969f218a4b2c70', - alternativeNames: [ - 'appleid-nc-s.apple.com', - 'appleid-nwk-s.apple.com', - 'appleid-prn-s.apple.com', - 'appleid-rno-s.apple.com', - 'appleid.apple.com', - ], - commonNames: ['appleid.apple.com'], - issuerNames: ['DigiCert SHA2 Extended Validation Server CA'], + subjects: ['appleid.apple.com'], + issuers: ['DigiCert SHA2 Extended Validation Server CA'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2020-07-04T12:00:00.000Z'], }, @@ -107,20 +86,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fc4a296b706fa18ac50b96f5c0327c69db4a8981' }, node: { _id: 'fc4a296b706fa18ac50b96f5c0327c69db4a8981', - alternativeNames: [ - 'api.itunes.apple.com', - 'appsto.re', - 'ax.init.itunes.apple.com', - 'bag.itunes.apple.com', - 'bookkeeper.itunes.apple.com', - 'c.itunes.apple.com', - 'carrierbundle.itunes.apple.com', - 'client-api.itunes.apple.com', - 'cma.itunes.apple.com', - 'courses.apple.com', - ], - commonNames: ['itunes.apple.com'], - issuerNames: ['DigiCert SHA2 Extended Validation Server CA'], + subjects: ['itunes.apple.com'], + issuers: ['DigiCert SHA2 Extended Validation Server CA'], ja3: ['a441a33aaee795f498d6b764cc78989a'], notAfter: ['2020-03-24T12:00:00.000Z'], }, @@ -129,20 +96,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fc2cbc41f6a0e9c0118de4fe40f299f7207b797e' }, node: { _id: 'fc2cbc41f6a0e9c0118de4fe40f299f7207b797e', - alternativeNames: [ - '*.adlercasino.com', - '*.allaustraliancasino.com', - '*.alletf.com', - '*.appareldesignpartners.com', - '*.atmosfir.net', - '*.cityofboston.gov', - '*.cp.mytoyotaentune.com', - '*.decathlon.be', - '*.decathlon.co.uk', - '*.decathlon.de', - ], - commonNames: ['incapsula.com'], - issuerNames: ['GlobalSign CloudSSL CA - SHA256 - G3'], + subjects: ['incapsula.com'], + issuers: ['GlobalSign CloudSSL CA - SHA256 - G3'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2020-04-04T14:05:06.000Z'], }, @@ -151,9 +106,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fb70d78ffa663a3a4374d841b3288d2de9759566' }, node: { _id: 'fb70d78ffa663a3a4374d841b3288d2de9759566', - alternativeNames: ['*.siriusxm.com', 'siriusxm.com'], - commonNames: ['*.siriusxm.com'], - issuerNames: ['DigiCert Baltimore CA-2 G2'], + subjects: ['*.siriusxm.com'], + issuers: ['DigiCert Baltimore CA-2 G2'], ja3: ['535aca3d99fc247509cd50933cd71d37', '6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2021-10-27T12:00:00.000Z'], }, @@ -162,16 +116,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fb59038dcec33ab3a01a6ae60d0835ad0e04ccf0' }, node: { _id: 'fb59038dcec33ab3a01a6ae60d0835ad0e04ccf0', - alternativeNames: [ - 'photos.amazon.co.uk', - 'photos.amazon.de', - 'photos.amazon.es', - 'photos.amazon.eu', - 'photos.amazon.fr', - 'photos.amazon.it', - ], - commonNames: ['photos.amazon.eu'], - issuerNames: ['Amazon'], + subjects: ['photos.amazon.eu'], + issuers: ['Amazon'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2020-04-23T12:00:00.000Z'], }, @@ -180,20 +126,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'f9815293c883a6006f0b2d95a4895bdc501fd174' }, node: { _id: 'f9815293c883a6006f0b2d95a4895bdc501fd174', - alternativeNames: [ - '*.api.cdn.hbo.com', - '*.artist.cdn.hbo.com', - '*.cdn.hbo.com', - '*.lv3.cdn.hbo.com', - 'artist.api.cdn.hbo.com', - 'artist.api.lv3.cdn.hbo.com', - 'artist.staging.cdn.hbo.com', - 'artist.staging.hurley.lv3.cdn.hbo.com', - 'atv.api.lv3.cdn.hbo.com', - 'atv.staging.hurley.lv3.cdn.hbo.com', - ], - commonNames: ['cdn.hbo.com'], - issuerNames: ['Sectigo RSA Organization Validation Secure Server CA'], + subjects: ['cdn.hbo.com'], + issuers: ['Sectigo RSA Organization Validation Secure Server CA'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2021-02-10T23:59:59.000Z'], }, @@ -202,9 +136,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'f8db6a69797e383dca2529727369595733123386' }, node: { _id: 'f8db6a69797e383dca2529727369595733123386', - alternativeNames: ['www.google.com'], - commonNames: ['www.google.com'], - issuerNames: ['GTS CA 1O1'], + subjects: ['www.google.com'], + issuers: ['GTS CA 1O1'], ja3: ['a111d93cdf31f993c40a8a9ef13e8d7e'], notAfter: ['2019-12-10T13:32:54.000Z'], }, @@ -226,7 +159,7 @@ export const mockRequest = { timerange: { interval: '12h', from: 1570716261267, to: 1570802661267 }, }, query: - 'query GetTlsQuery($sourceId: ID!, $filterQuery: String, $flowTarget: FlowTarget!, $ip: String!, $pagination: PaginationInputPaginated!, $sort: TlsSortField!, $timerange: TimerangeInput!, $defaultIndex: [String!]!, $inspect: Boolean!) {\n source(id: $sourceId) {\n id\n Tls(filterQuery: $filterQuery, flowTarget: $flowTarget, ip: $ip, pagination: $pagination, sort: $sort, timerange: $timerange, defaultIndex: $defaultIndex) {\n totalCount\n edges {\n node {\n _id\n alternativeNames\n commonNames\n ja3\n issuerNames\n notAfter\n __typename\n }\n cursor {\n value\n __typename\n }\n __typename\n }\n pageInfo {\n activePage\n fakeTotalCount\n showMorePagesIndicator\n __typename\n }\n inspect @include(if: $inspect) {\n dsl\n response\n __typename\n }\n __typename\n }\n __typename\n }\n}\n', + 'query GetTlsQuery($sourceId: ID!, $filterQuery: String, $flowTarget: FlowTarget!, $ip: String!, $pagination: PaginationInputPaginated!, $sort: TlsSortField!, $timerange: TimerangeInput!, $defaultIndex: [String!]!, $inspect: Boolean!) {\n source(id: $sourceId) {\n id\n Tls(filterQuery: $filterQuery, flowTarget: $flowTarget, ip: $ip, pagination: $pagination, sort: $sort, timerange: $timerange, defaultIndex: $defaultIndex) {\n totalCount\n edges {\n node {\n _id\n subjects\n ja3\n issuers\n notAfter\n __typename\n }\n cursor {\n value\n __typename\n }\n __typename\n }\n pageInfo {\n activePage\n fakeTotalCount\n showMorePagesIndicator\n __typename\n }\n inspect @include(if: $inspect) {\n dsl\n response\n __typename\n }\n __typename\n }\n __typename\n }\n}\n', }, }; @@ -250,28 +183,16 @@ export const mockResponse = { { key: 1572177600000, key_as_string: '2019-10-27T12:00:00.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'DigiCert SHA2 Secure Server CA', doc_count: 1 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: '*.1.nflxso.net', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: '*.1.nflxso.net', doc_count: 1 }, - { key: '*.a.nflxso.net', doc_count: 1 }, - { key: 'assets.nflxext.com', doc_count: 1 }, - { key: 'cast.netflix.com', doc_count: 1 }, - { key: 'codex.nflxext.com', doc_count: 1 }, - { key: 'tvui.netflix.com', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -288,24 +209,16 @@ export const mockResponse = { { key: 1580558400000, key_as_string: '2020-02-01T12:00:00.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'Amazon', doc_count: 1 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'cogocast.net', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: '*.cogocast.net', doc_count: 1 }, - { key: 'cogocast.net', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -322,26 +235,18 @@ export const mockResponse = { { key: 1583531829000, key_as_string: '2020-03-06T21:57:09.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [ { key: 'Trustwave Organization Validation SHA256 CA, Level 1', doc_count: 1 }, ], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'player-devintever2.mountain.siriusxm.com', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'player-devintever2-imperva.mountain.siriusxm.com', doc_count: 1 }, - { key: 'player-devintever2.mountain.siriusxm.com', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -358,27 +263,16 @@ export const mockResponse = { { key: 1593864000000, key_as_string: '2020-07-04T12:00:00.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'DigiCert SHA2 Extended Validation Server CA', doc_count: 1 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'appleid.apple.com', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'appleid-nc-s.apple.com', doc_count: 1 }, - { key: 'appleid-nwk-s.apple.com', doc_count: 1 }, - { key: 'appleid-prn-s.apple.com', doc_count: 1 }, - { key: 'appleid-rno-s.apple.com', doc_count: 1 }, - { key: 'appleid.apple.com', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -395,32 +289,16 @@ export const mockResponse = { { key: 1585051200000, key_as_string: '2020-03-24T12:00:00.000Z', doc_count: 2 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'DigiCert SHA2 Extended Validation Server CA', doc_count: 2 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'itunes.apple.com', doc_count: 2 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 156, - buckets: [ - { key: 'api.itunes.apple.com', doc_count: 2 }, - { key: 'appsto.re', doc_count: 2 }, - { key: 'ax.init.itunes.apple.com', doc_count: 2 }, - { key: 'bag.itunes.apple.com', doc_count: 2 }, - { key: 'bookkeeper.itunes.apple.com', doc_count: 2 }, - { key: 'c.itunes.apple.com', doc_count: 2 }, - { key: 'carrierbundle.itunes.apple.com', doc_count: 2 }, - { key: 'client-api.itunes.apple.com', doc_count: 2 }, - { key: 'cma.itunes.apple.com', doc_count: 2 }, - { key: 'courses.apple.com', doc_count: 2 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -437,32 +315,16 @@ export const mockResponse = { { key: 1586009106000, key_as_string: '2020-04-04T14:05:06.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'GlobalSign CloudSSL CA - SHA256 - G3', doc_count: 1 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'incapsula.com', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 110, - buckets: [ - { key: '*.adlercasino.com', doc_count: 1 }, - { key: '*.allaustraliancasino.com', doc_count: 1 }, - { key: '*.alletf.com', doc_count: 1 }, - { key: '*.appareldesignpartners.com', doc_count: 1 }, - { key: '*.atmosfir.net', doc_count: 1 }, - { key: '*.cityofboston.gov', doc_count: 1 }, - { key: '*.cp.mytoyotaentune.com', doc_count: 1 }, - { key: '*.decathlon.be', doc_count: 1 }, - { key: '*.decathlon.co.uk', doc_count: 1 }, - { key: '*.decathlon.de', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -479,24 +341,16 @@ export const mockResponse = { { key: 1635336000000, key_as_string: '2021-10-27T12:00:00.000Z', doc_count: 325 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'DigiCert Baltimore CA-2 G2', doc_count: 325 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: '*.siriusxm.com', doc_count: 325 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: '*.siriusxm.com', doc_count: 325 }, - { key: 'siriusxm.com', doc_count: 325 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -516,28 +370,16 @@ export const mockResponse = { { key: 1587643200000, key_as_string: '2020-04-23T12:00:00.000Z', doc_count: 5 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'Amazon', doc_count: 5 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'photos.amazon.eu', doc_count: 5 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'photos.amazon.co.uk', doc_count: 5 }, - { key: 'photos.amazon.de', doc_count: 5 }, - { key: 'photos.amazon.es', doc_count: 5 }, - { key: 'photos.amazon.eu', doc_count: 5 }, - { key: 'photos.amazon.fr', doc_count: 5 }, - { key: 'photos.amazon.it', doc_count: 5 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -554,34 +396,18 @@ export const mockResponse = { { key: 1613001599000, key_as_string: '2021-02-10T23:59:59.000Z', doc_count: 29 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [ { key: 'Sectigo RSA Organization Validation Secure Server CA', doc_count: 29 }, ], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'cdn.hbo.com', doc_count: 29 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 29, - buckets: [ - { key: '*.api.cdn.hbo.com', doc_count: 29 }, - { key: '*.artist.cdn.hbo.com', doc_count: 29 }, - { key: '*.cdn.hbo.com', doc_count: 29 }, - { key: '*.lv3.cdn.hbo.com', doc_count: 29 }, - { key: 'artist.api.cdn.hbo.com', doc_count: 29 }, - { key: 'artist.api.lv3.cdn.hbo.com', doc_count: 29 }, - { key: 'artist.staging.cdn.hbo.com', doc_count: 29 }, - { key: 'artist.staging.hurley.lv3.cdn.hbo.com', doc_count: 29 }, - { key: 'atv.api.lv3.cdn.hbo.com', doc_count: 29 }, - { key: 'atv.staging.hurley.lv3.cdn.hbo.com', doc_count: 29 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -598,17 +424,12 @@ export const mockResponse = { { key: 1575984774000, key_as_string: '2019-12-10T13:32:54.000Z', doc_count: 5 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'GTS CA 1O1', doc_count: 5 }], }, - common_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'www.google.com', doc_count: 5 }], - }, - alternative_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'www.google.com', doc_count: 5 }], @@ -643,10 +464,9 @@ export const mockOptions = { fields: [ 'totalCount', '_id', - 'alternativeNames', - 'commonNames', + 'subjects', 'ja3', - 'issuerNames', + 'issuers', 'notAfter', 'edges.cursor.value', 'pageInfo.activePage', diff --git a/x-pack/legacy/plugins/siem/server/lib/tls/query_tls.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/tls/query_tls.dsl.ts index 2ff33a800fcd5..bc65be642dabc 100644 --- a/x-pack/legacy/plugins/siem/server/lib/tls/query_tls.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/tls/query_tls.dsl.ts @@ -12,41 +12,36 @@ import { TlsSortField, Direction, TlsFields } from '../../graphql/types'; const getAggs = (querySize: number, sort: TlsSortField) => ({ count: { cardinality: { - field: 'tls.server_certificate.fingerprint.sha1', + field: 'tls.server.hash.sha1', }, }, sha1: { terms: { - field: 'tls.server_certificate.fingerprint.sha1', + field: 'tls.server.hash.sha1', size: querySize, order: { ...getQueryOrder(sort), }, }, aggs: { - issuer_names: { + issuers: { terms: { - field: 'tls.server_certificate.issuer.common_name', + field: 'tls.server.issuer', }, }, - common_names: { + subjects: { terms: { - field: 'tls.server_certificate.subject.common_name', - }, - }, - alternative_names: { - terms: { - field: 'tls.server_certificate.alternative_names', + field: 'tls.server.subject', }, }, not_after: { terms: { - field: 'tls.server_certificate.not_after', + field: 'tls.server.not_after', }, }, ja3: { terms: { - field: 'tls.fingerprints.ja3.hash', + field: 'tls.server.ja3s', }, }, }, diff --git a/x-pack/legacy/plugins/siem/server/lib/tls/types.ts b/x-pack/legacy/plugins/siem/server/lib/tls/types.ts index bac5426f72e08..1fbb31ba3e0f3 100644 --- a/x-pack/legacy/plugins/siem/server/lib/tls/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/tls/types.ts @@ -18,11 +18,7 @@ export interface TlsBuckets { value_as_string: string; }; - alternative_names: { - buckets: Readonly>; - }; - - common_names: { + subjects: { buckets: Readonly>; }; @@ -30,7 +26,7 @@ export interface TlsBuckets { buckets: Readonly>; }; - issuer_names: { + issuers: { buckets: Readonly>; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/uncommon_processes/query.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/uncommon_processes/query.dsl.ts index dc38824989da3..24cae53d5d353 100644 --- a/x-pack/legacy/plugins/siem/server/lib/uncommon_processes/query.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/uncommon_processes/query.dsl.ts @@ -191,6 +191,22 @@ export const buildQuery = ({ ], }, }, + { + bool: { + filter: [ + { + term: { + 'event.category': 'process', + }, + }, + { + term: { + 'event.type': 'start', + }, + }, + ], + }, + }, ], minimum_should_match: 1, filter, diff --git a/x-pack/test/api_integration/apis/siem/tls.ts b/x-pack/test/api_integration/apis/siem/tls.ts index 949ed530e9b27..8467308d709af 100644 --- a/x-pack/test/api_integration/apis/siem/tls.ts +++ b/x-pack/test/api_integration/apis/siem/tls.ts @@ -16,17 +16,16 @@ import { FtrProviderContext } from '../../ftr_provider_context'; const FROM = new Date('2000-01-01T00:00:00.000Z').valueOf(); const TO = new Date('3000-01-01T00:00:00.000Z').valueOf(); -const SOURCE_IP = '157.230.208.30'; -const DESTINATION_IP = '91.189.92.20'; +const SOURCE_IP = '10.128.0.35'; +const DESTINATION_IP = '74.125.129.95'; const expectedResult = { __typename: 'TlsNode', - _id: '61749734b3246f1584029deb4f5276c64da00ada', - alternativeNames: ['api.snapcraft.io'], - commonNames: ['api.snapcraft.io'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], - ja3: ['839868ad711dc55bde0d37a87f14740d'], - notAfter: ['2019-05-22T12:00:00.000Z'], + _id: '16989191B1A93ECECD5FE9E63EBD4B5C3B606D26', + subjects: ['CN=edgecert.googleapis.com,O=Google LLC,L=Mountain View,ST=California,C=US'], + issuers: ['CN=GTS CA 1O1,O=Google Trust Services,C=US'], + ja3: [], + notAfter: ['2020-05-06T11:52:15.000Z'], }; const expectedOverviewDestinationResult = { @@ -36,27 +35,29 @@ const expectedOverviewDestinationResult = { __typename: 'TlsEdges', cursor: { __typename: 'CursorType', - value: '61749734b3246f1584029deb4f5276c64da00ada', + value: 'EB4E81DD7C55BA9715652ECF5647FB8877E55A8F', }, node: { __typename: 'TlsNode', - _id: '61749734b3246f1584029deb4f5276c64da00ada', - alternativeNames: ['api.snapcraft.io'], - commonNames: ['api.snapcraft.io'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], - ja3: ['839868ad711dc55bde0d37a87f14740d'], - notAfter: ['2019-05-22T12:00:00.000Z'], + _id: 'EB4E81DD7C55BA9715652ECF5647FB8877E55A8F', + subjects: [ + 'CN=*.cdn.mozilla.net,OU=Cloud Services,O=Mozilla Corporation,L=Mountain View,ST=California,C=US', + ], + issuers: ['CN=DigiCert SHA2 Secure Server CA,O=DigiCert Inc,C=US'], + ja3: [], + notAfter: ['2020-12-09T12:00:00.000Z'], }, }, ], pageInfo: { __typename: 'PageInfoPaginated', activePage: 0, - fakeTotalCount: 1, + fakeTotalCount: 3, showMorePagesIndicator: false, }, - totalCount: 1, + totalCount: 3, }; + const expectedOverviewSourceResult = { __typename: 'TlsData', edges: [ @@ -64,26 +65,27 @@ const expectedOverviewSourceResult = { __typename: 'TlsEdges', cursor: { __typename: 'CursorType', - value: '61749734b3246f1584029deb4f5276c64da00ada', + value: 'EB4E81DD7C55BA9715652ECF5647FB8877E55A8F', }, node: { __typename: 'TlsNode', - _id: '61749734b3246f1584029deb4f5276c64da00ada', - alternativeNames: ['api.snapcraft.io'], - commonNames: ['api.snapcraft.io'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], - ja3: ['839868ad711dc55bde0d37a87f14740d'], - notAfter: ['2019-05-22T12:00:00.000Z'], + _id: 'EB4E81DD7C55BA9715652ECF5647FB8877E55A8F', + subjects: [ + 'CN=*.cdn.mozilla.net,OU=Cloud Services,O=Mozilla Corporation,L=Mountain View,ST=California,C=US', + ], + issuers: ['CN=DigiCert SHA2 Secure Server CA,O=DigiCert Inc,C=US'], + ja3: [], + notAfter: ['2020-12-09T12:00:00.000Z'], }, }, ], pageInfo: { __typename: 'PageInfoPaginated', activePage: 0, - fakeTotalCount: 1, + fakeTotalCount: 3, showMorePagesIndicator: false, }, - totalCount: 1, + totalCount: 3, }; export default function({ getService }: FtrProviderContext) { @@ -91,8 +93,8 @@ export default function({ getService }: FtrProviderContext) { const client = getService('siemGraphQLClient'); describe('Tls Test with Packetbeat', () => { describe('Tls Test', () => { - before(() => esArchiver.load('packetbeat/default')); - after(() => esArchiver.unload('packetbeat/default')); + before(() => esArchiver.load('packetbeat/tls')); + after(() => esArchiver.unload('packetbeat/tls')); it('Ensure data is returned for FlowTarget.Source', () => { return client @@ -160,8 +162,8 @@ export default function({ getService }: FtrProviderContext) { }); describe('Tls Overview Test', () => { - before(() => esArchiver.load('packetbeat/default')); - after(() => esArchiver.unload('packetbeat/default')); + before(() => esArchiver.load('packetbeat/tls')); + after(() => esArchiver.unload('packetbeat/tls')); it('Ensure data is returned for FlowTarget.Source', () => { return client @@ -189,7 +191,8 @@ export default function({ getService }: FtrProviderContext) { }) .then(resp => { const tls = resp.data.source.Tls; - expect(tls).to.eql(expectedOverviewSourceResult); + expect(tls.pageInfo).to.eql(expectedOverviewSourceResult.pageInfo); + expect(tls.edges[0]).to.eql(expectedOverviewSourceResult.edges[0]); }); }); @@ -219,7 +222,8 @@ export default function({ getService }: FtrProviderContext) { }) .then(resp => { const tls = resp.data.source.Tls; - expect(tls).to.eql(expectedOverviewDestinationResult); + expect(tls.pageInfo).to.eql(expectedOverviewDestinationResult.pageInfo); + expect(tls.edges[0]).to.eql(expectedOverviewDestinationResult.edges[0]); }); }); }); diff --git a/x-pack/test/functional/es_archives/packetbeat/tls/data.json.gz b/x-pack/test/functional/es_archives/packetbeat/tls/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..cf7a5e5f0d4467ffe4a051ee92be733018ff792c GIT binary patch literal 3929 zcmV-f52o-RiwFox#dux-17u-zVJ>QOZ*BnXU29X@$eRA1U*Y1@+1=RN_e-i$b-<7? zH6+XgX3k``rd+L7%jh6W9!VyFQ~B@PEn|=+*_I(dNJJ4QsNcT5_wMHv`Ok}9ZzsQ; znw?j@9Y4`KFWrL+70uj}|JWbwkD>2cp*h`4gqHJQ4RT*toa#ZCc#h2bmA z1`LotulrkiH8ycx8o)j>(=#(MsftGPH46m*LO$(lUxGgI0@fFXz`l^i2v1-EIlCFq z_CHIqJXVu}8)>4?ZH`fuDgt2)!{uHJ?%uMd+?CXe|RwZaI!o4*H5R1quqm(k)3$-cKB{|a=44=Qm}3ajP~ij z6)sE!0gn(Hm_0$U36NAC=h((>=mAU+T2`bv*GCJDY{6#NX*P8nF#c%13AeNVD-rbO zds@4R8>->quKh+}^vm(lM~DGkL+eJgO^Xqi4gQ8|&P)5*ikp>g?L@G8u5GqcnQ{BB zl|8m)&4;^mYM5)dH+0>$lEKD4H34kbW5c?(onhPYw;B6Ru45F zRhn6sQS%i=JHnM+7f(|Y+dM4~i(G3I#zE3VHFvm+#Wzt<*z}aprl&|WK7~7#hksS6 zcGPqlIX=3zM(WbK)kUBjmA28m|1;Dy>sg*p{}&Eg%x&~&nuKwlja=HG^14ed$Y!yP z`cs?!SxIajv=kgH^Zo0+HIKY9aj(e#T(3^>|md)Ep2iVa$i-h+cimp5-}6ve!$aRc)vP+Q^B&tH~1-Wc9I z{RaU-5Gm;+tbG#%!oa{lk1?Oy1Fn$FaIA>sk)hhB08lLg&j??58X1miSxPBMymmQm zu8GOd62FNpJMVuxI$GX9g~8=h54PMZH7;#!;kQbWtsbsal-qNgb!Zo+aBz)F`tP-Q za6z)x-BmXbX4$NA##szw4`Y@+i#&Wk9EU@@{GQ7IdnZO)*JuB7+v*Lg2}03*7U%Q( zjNeY`%}F{|ad@?G-?zj66ziR}Iua-ONCo-)pi*2#K-35F6rxuE*zW-VOBWh9ojemH z^P2z!pme<;I4!!Y>NTBtHhhlG&E>6=cl~YZvxYPauiPxocx&pNhRTNC*~D8cp9_d7 zgaS!O1cQJuZWjk2#ZpMl8RE4(q$%{c!RD!XzhQCN%xk!UYm#ys^;$33DACH@Ub^;o zj?^BPd%uU~(`FrbH=njQOj2v7^T%sDSzfy9g5u)B-K7@#7;2N%yA{`n^Q+RGpR?nmvTVq- zBCAe~sgUbjWIk(?Ge{}-fRvaLtQ2615(?5qXGySA-*NS(prkI zf@)sdI+H5VEfUklI|hedTBFX+imQV>os}>4?S^NQ#w-7ZJ70I|RoPix4T}SYUB|&5 zIS_DvpARYB+m#$rM$y6W4P}JC*%N~E14?(ro7J1Ti3K~u_pjHR@5|%YW#fA}d|k2F zJ0wVVNsme%$t}8;IJ`Xm?e%g{wkvOl@Gp;EFQo}DPflOo2I%?r8U?%rvn}0)U$=~^ zpumgDAUD_lH;v9AeR=Wa#S16twpje^>9Kf%IxN0eNlSpQUiM+56yMUfUWcEJ4#iVnE1_V0lEhXU>mU=%A(aNtuaDD=2t(kKOh7a&d?SnnPx z5@;VW1}O8i7ho0Wz@wB^{pxoD+;2>bZzf#t_`W{~AN4_T+x#3Md@P`Y`3Qgjar-4C z5CS4<|0M|GRM-PlU7;Tm!EdK$9gM%;%sUvrftL-o+rju9j9)%OHksAa9{C>n;Pq68 zs|N@C9~F|1tu`znwo6CauZ-9edSE@!F5v{qeFOlJRD!*OJ$5j}co!1J_x6WxAY8rY z2Sw~R=R;owv2RKgejM1o|KzZ}1hRweiKY#sv ze)LQ7S&qe%L=s>5zR!@5IPfJ<5P^UOTrlk!UjVKGL_h!#<%Ws? zLDs!)-@R^M?(_{}_1)|C9Zuij^c_y$;q*Vy>-HVG-{y7xAV8WbAPrH#W`F_5Mrnz> z0C^ZIj*Rg+Q_L5VDFg%&3MmgOYjheCgKI(a`>h@5-uC~I-}Zke=4AuN$G>IgZ=+v6 zpS}O}QOgFzfU)jv|M^OmaU0x-0U<_N!)RUEz@wB6M9Fv@#^b>_Nyd>;(=cm(0hbdi zL~8%Y=UgfPB@-BX4be zz(zjzT9*Xe17rUhp!FT_{5#?5_d@dTQ9;leGQT%4e;4?FTj+efq_)(DhXUp+ys5$S z>xTbMv3!UjZ$zy&1NH4LexUenFMHYp?kl|V<~ds9=id&3-5$wrgLSt@`?o;F+g1eJ zBIK*bdN%m^7K?0yfpISAp(XO?n)>Rj8PfvQo-5xxyJDzPT zEZ!FD>z$^vEWZWH*ZQdXRX(8ZvIoLf6upA9RjGyRh58V!@3ji9Epqo3#TJy+{A&fP zg_v>*0AZA1K{1wqyDL#bDW+6N!kLgjR(=0N7F)D-a1U2UGy65BB*U81h=->qTi!bLO>#!lvLVeg$jTf zkT!?G*18Z84Jqfp+F83ogTjvQ z{a*3RT?~pi&vvOP~M%xznae literal 0 HcmV?d00001 diff --git a/x-pack/test/functional/es_archives/packetbeat/tls/mappings.json b/x-pack/test/functional/es_archives/packetbeat/tls/mappings.json new file mode 100644 index 0000000000000..2b5ed05c7e8a9 --- /dev/null +++ b/x-pack/test/functional/es_archives/packetbeat/tls/mappings.json @@ -0,0 +1,9583 @@ +{ + "type": "index", + "value": { + "aliases": { + "packetbeat-7.6.0": { + "is_write_index": false + }, + "packetbeat-tls": { + "filter": { + "term": { + "event.dataset": "tls" + } + } + } + }, + "index": "packetbeat-7.6.0-2020.03.03-000001", + "mappings": { + "_meta": { + "beat": "packetbeat", + "version": "7.6.0" + }, + "date_detection": false, + "dynamic_templates": [ + { + "labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "labels.*" + } + }, + { + "container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "container.labels.*" + } + }, + { + "dns.answers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "dns.answers.*" + } + }, + { + "log.syslog": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "log.syslog.*" + } + }, + { + "fields": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "fields.*" + } + }, + { + "docker.container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "docker.container.labels.*" + } + }, + { + "kubernetes.labels.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.labels.*" + } + }, + { + "kubernetes.annotations.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.annotations.*" + } + }, + { + "amqp.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "amqp.headers.*" + } + }, + { + "cassandra.response.supported": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "cassandra.response.supported.*" + } + }, + { + "http.request.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "http.request.headers.*" + } + }, + { + "http.response.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "http.response.headers.*" + } + }, + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "amqp": { + "properties": { + "app-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "arguments": { + "type": "object" + }, + "auto-delete": { + "type": "boolean" + }, + "class-id": { + "type": "long" + }, + "consumer-count": { + "type": "long" + }, + "consumer-tag": { + "ignore_above": 1024, + "type": "keyword" + }, + "content-encoding": { + "ignore_above": 1024, + "type": "keyword" + }, + "content-type": { + "ignore_above": 1024, + "type": "keyword" + }, + "correlation-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "delivery-mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "delivery-tag": { + "type": "long" + }, + "durable": { + "type": "boolean" + }, + "exchange": { + "ignore_above": 1024, + "type": "keyword" + }, + "exchange-type": { + "ignore_above": 1024, + "type": "keyword" + }, + "exclusive": { + "type": "boolean" + }, + "expiration": { + "ignore_above": 1024, + "type": "keyword" + }, + "headers": { + "type": "object" + }, + "if-empty": { + "type": "boolean" + }, + "if-unused": { + "type": "boolean" + }, + "immediate": { + "type": "boolean" + }, + "mandatory": { + "type": "boolean" + }, + "message-count": { + "type": "long" + }, + "message-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "method-id": { + "type": "long" + }, + "multiple": { + "type": "boolean" + }, + "no-ack": { + "type": "boolean" + }, + "no-local": { + "type": "boolean" + }, + "no-wait": { + "type": "boolean" + }, + "passive": { + "type": "boolean" + }, + "priority": { + "type": "long" + }, + "queue": { + "ignore_above": 1024, + "type": "keyword" + }, + "redelivered": { + "type": "boolean" + }, + "reply-code": { + "type": "long" + }, + "reply-text": { + "ignore_above": 1024, + "type": "keyword" + }, + "reply-to": { + "ignore_above": 1024, + "type": "keyword" + }, + "routing-key": { + "ignore_above": 1024, + "type": "keyword" + }, + "timestamp": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "user-id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes_in": { + "path": "source.bytes", + "type": "alias" + }, + "bytes_out": { + "path": "destination.bytes", + "type": "alias" + }, + "cassandra": { + "properties": { + "no_request": { + "type": "boolean" + }, + "request": { + "properties": { + "headers": { + "properties": { + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "length": { + "type": "long" + }, + "op": { + "ignore_above": 1024, + "type": "keyword" + }, + "stream": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "authentication": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "type": "long" + }, + "details": { + "properties": { + "alive": { + "type": "long" + }, + "arg_types": { + "ignore_above": 1024, + "type": "keyword" + }, + "blockfor": { + "type": "long" + }, + "data_present": { + "type": "boolean" + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_failures": { + "ignore_above": 1024, + "type": "keyword" + }, + "read_consistency": { + "ignore_above": 1024, + "type": "keyword" + }, + "received": { + "type": "long" + }, + "required": { + "type": "long" + }, + "stmt_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "write_type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "msg": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "host": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "schema_change": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "object": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "target": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "headers": { + "properties": { + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "length": { + "type": "long" + }, + "op": { + "ignore_above": 1024, + "type": "keyword" + }, + "stream": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "result": { + "properties": { + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "prepared": { + "properties": { + "prepared_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "req_meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resp_meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "rows": { + "properties": { + "meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "num_rows": { + "type": "long" + } + } + }, + "schema_change": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "object": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "target": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "supported": { + "type": "object" + }, + "warnings": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "client": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "start": { + "ignore_above": 1024, + "type": "keyword" + }, + "working_directory": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "availability_zone": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "instance": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "machine": { + "properties": { + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "project": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "container": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "runtime": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dhcpv4": { + "properties": { + "assigned_ip": { + "type": "ip" + }, + "client_ip": { + "type": "ip" + }, + "client_mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "hardware_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "hops": { + "type": "long" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "option": { + "properties": { + "boot_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "broadcast_address": { + "type": "ip" + }, + "class_identifier": { + "ignore_above": 1024, + "type": "keyword" + }, + "dns_servers": { + "type": "ip" + }, + "domain_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip_address_lease_time_sec": { + "type": "long" + }, + "max_dhcp_message_size": { + "type": "long" + }, + "message": { + "norms": false, + "type": "text" + }, + "message_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "ntp_servers": { + "type": "ip" + }, + "parameter_request_list": { + "ignore_above": 1024, + "type": "keyword" + }, + "rebinding_time_sec": { + "type": "long" + }, + "renewal_time_sec": { + "type": "long" + }, + "requested_ip_address": { + "type": "ip" + }, + "router": { + "type": "ip" + }, + "server_identifier": { + "type": "ip" + }, + "subnet_mask": { + "type": "ip" + }, + "time_servers": { + "type": "ip" + }, + "utc_time_offset_sec": { + "type": "long" + }, + "vendor_identifying_options": { + "type": "object" + } + } + }, + "relay_ip": { + "type": "ip" + }, + "seconds": { + "type": "long" + }, + "server_ip": { + "type": "ip" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "transaction_id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "dns": { + "properties": { + "additionals": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "additionals_count": { + "type": "long" + }, + "answers": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "answers_count": { + "type": "long" + }, + "authorities": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "authorities_count": { + "type": "long" + }, + "flags": { + "properties": { + "authentic_data": { + "type": "boolean" + }, + "authoritative": { + "type": "boolean" + }, + "checking_disabled": { + "type": "boolean" + }, + "recursion_available": { + "type": "boolean" + }, + "recursion_desired": { + "type": "boolean" + }, + "truncated_response": { + "type": "boolean" + } + } + }, + "header_flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "opt": { + "properties": { + "cookie": { + "ignore_above": 1024, + "type": "keyword" + }, + "do": { + "type": "boolean" + }, + "ext_rcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "udp_size": { + "type": "long" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "question": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "etld_plus_one": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "subdomain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resolved_ip": { + "type": "ip" + }, + "response_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "docker": { + "properties": { + "container": { + "properties": { + "labels": { + "type": "object" + } + } + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "message": { + "norms": false, + "type": "text" + }, + "stack_trace": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingested": { + "type": "date" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "outcome": { + "ignore_above": 1024, + "type": "keyword" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "fields": { + "properties": { + "labels": { + "properties": { + "responsible_human": { + "type": "keyword" + } + } + } + } + }, + "file": { + "properties": { + "accessed": { + "type": "date" + }, + "attributes": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "ctime": { + "type": "date" + }, + "device": { + "ignore_above": 1024, + "type": "keyword" + }, + "directory": { + "ignore_above": 1024, + "type": "keyword" + }, + "drive_letter": { + "ignore_above": 1, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "inode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mtime": { + "type": "date" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "owner": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "target_path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "flow": { + "properties": { + "final": { + "type": "boolean" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "vlan": { + "type": "long" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "containerized": { + "type": "boolean" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "build": { + "ignore_above": 1024, + "type": "keyword" + }, + "codename": { + "ignore_above": 1024, + "type": "keyword" + }, + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "headers": { + "properties": { + "content-length": { + "type": "long" + }, + "content-type": { + "type": "keyword" + } + } + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "referrer": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "headers": { + "properties": { + "content-length": { + "type": "long" + }, + "content-type": { + "type": "keyword" + } + } + }, + "status_code": { + "type": "long" + }, + "status_phrase": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "icmp": { + "properties": { + "request": { + "properties": { + "code": { + "type": "long" + }, + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "type": "long" + } + } + }, + "response": { + "properties": { + "code": { + "type": "long" + }, + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "type": "long" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "jolokia": { + "properties": { + "agent": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "secured": { + "type": "boolean" + }, + "server": { + "properties": { + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "kubernetes": { + "properties": { + "annotations": { + "properties": { + "*": { + "type": "object" + } + } + }, + "container": { + "properties": { + "image": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "deployment": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "properties": { + "*": { + "type": "object" + } + } + }, + "namespace": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pod": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "replicaset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "statefulset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "logger": { + "ignore_above": 1024, + "type": "keyword" + }, + "origin": { + "properties": { + "file": { + "properties": { + "line": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "syslog": { + "properties": { + "facility": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "priority": { + "type": "long" + }, + "severity": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "memcache": { + "properties": { + "protocol_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "request": { + "properties": { + "automove": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "cas_unique": { + "type": "long" + }, + "command": { + "ignore_above": 1024, + "type": "keyword" + }, + "count_values": { + "type": "long" + }, + "delta": { + "type": "long" + }, + "dest_class": { + "type": "long" + }, + "exptime": { + "type": "long" + }, + "flags": { + "type": "long" + }, + "initial": { + "type": "long" + }, + "line": { + "ignore_above": 1024, + "type": "keyword" + }, + "noreply": { + "type": "boolean" + }, + "opaque": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "opcode_value": { + "type": "long" + }, + "quiet": { + "type": "boolean" + }, + "raw_args": { + "ignore_above": 1024, + "type": "keyword" + }, + "sleep_us": { + "type": "long" + }, + "source_class": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vbucket": { + "type": "long" + }, + "verbosity": { + "type": "long" + } + } + }, + "response": { + "properties": { + "bytes": { + "type": "long" + }, + "cas_unique": { + "type": "long" + }, + "command": { + "ignore_above": 1024, + "type": "keyword" + }, + "count_values": { + "type": "long" + }, + "error_msg": { + "ignore_above": 1024, + "type": "keyword" + }, + "flags": { + "type": "long" + }, + "opaque": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "opcode_value": { + "type": "long" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "status_code": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "type": "long" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "message": { + "norms": false, + "type": "text" + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "mongodb": { + "properties": { + "cursorId": { + "ignore_above": 1024, + "type": "keyword" + }, + "error": { + "ignore_above": 1024, + "type": "keyword" + }, + "fullCollectionName": { + "ignore_above": 1024, + "type": "keyword" + }, + "numberReturned": { + "type": "long" + }, + "numberToReturn": { + "type": "long" + }, + "numberToSkip": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "returnFieldsSelector": { + "ignore_above": 1024, + "type": "keyword" + }, + "selector": { + "ignore_above": 1024, + "type": "keyword" + }, + "startingFrom": { + "ignore_above": 1024, + "type": "keyword" + }, + "update": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "mysql": { + "properties": { + "affected_rows": { + "type": "long" + }, + "error_code": { + "type": "long" + }, + "error_message": { + "ignore_above": 1024, + "type": "keyword" + }, + "insert_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_fields": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_rows": { + "ignore_above": 1024, + "type": "keyword" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "network": { + "properties": { + "application": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "community_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "direction": { + "ignore_above": 1024, + "type": "keyword" + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "packets": { + "type": "long" + }, + "protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "transport": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "nfs": { + "properties": { + "minor_version": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "notes": { + "path": "error.message", + "type": "alias" + }, + "observer": { + "properties": { + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "organization": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "package": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "build_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "checksum": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "install_scope": { + "ignore_above": 1024, + "type": "keyword" + }, + "installed": { + "type": "date" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "params": { + "norms": false, + "type": "text" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "pgsql": { + "properties": { + "error_code": { + "type": "long" + }, + "error_message": { + "ignore_above": 1024, + "type": "keyword" + }, + "error_severity": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_fields": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_rows": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "parent": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "redis": { + "properties": { + "error": { + "ignore_above": 1024, + "type": "keyword" + }, + "return_value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registry": { + "properties": { + "data": { + "properties": { + "bytes": { + "ignore_above": 1024, + "type": "keyword" + }, + "strings": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hive": { + "ignore_above": 1024, + "type": "keyword" + }, + "key": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "related": { + "properties": { + "ip": { + "type": "ip" + }, + "user": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "request": { + "norms": false, + "type": "text" + }, + "resource": { + "ignore_above": 1024, + "type": "keyword" + }, + "response": { + "norms": false, + "type": "text" + }, + "rpc": { + "properties": { + "auth_flavor": { + "ignore_above": 1024, + "type": "keyword" + }, + "cred": { + "properties": { + "gid": { + "type": "long" + }, + "gids": { + "ignore_above": 1024, + "type": "keyword" + }, + "machinename": { + "ignore_above": 1024, + "type": "keyword" + }, + "stamp": { + "type": "long" + }, + "uid": { + "type": "long" + } + } + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "xid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "ruleset": { + "ignore_above": 1024, + "type": "keyword" + }, + "uuid": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "start": { + "ignore_above": 1024, + "type": "keyword" + }, + "working_directory": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "source": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "tags": { + "ignore_above": 1024, + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "ignore_above": 1024, + "type": "keyword" + }, + "tactic": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "thrift": { + "properties": { + "exceptions": { + "ignore_above": 1024, + "type": "keyword" + }, + "params": { + "ignore_above": 1024, + "type": "keyword" + }, + "return_value": { + "ignore_above": 1024, + "type": "keyword" + }, + "service": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "timeseries": { + "properties": { + "instance": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tls": { + "properties": { + "alert_types": { + "path": "tls.detailed.alert_types", + "type": "alias" + }, + "cipher": { + "ignore_above": 1024, + "type": "keyword" + }, + "client": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_ciphers": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "client_certificate": { + "properties": { + "alternative_names": { + "path": "tls.detailed.client_certificate.alternative_names", + "type": "alias" + }, + "issuer": { + "properties": { + "common_name": { + "path": "tls.detailed.client_certificate.issuer.common_name", + "type": "alias" + }, + "country": { + "path": "tls.detailed.client_certificate.issuer.country", + "type": "alias" + }, + "locality": { + "path": "tls.detailed.client_certificate.issuer.locality", + "type": "alias" + }, + "organization": { + "path": "tls.detailed.client_certificate.issuer.organization", + "type": "alias" + }, + "organizational_unit": { + "path": "tls.detailed.client_certificate.issuer.organizational_unit", + "type": "alias" + }, + "province": { + "path": "tls.detailed.client_certificate.issuer.province", + "type": "alias" + } + } + }, + "not_after": { + "path": "tls.detailed.client_certificate.not_after", + "type": "alias" + }, + "not_before": { + "path": "tls.detailed.client_certificate.not_before", + "type": "alias" + }, + "public_key_algorithm": { + "path": "tls.detailed.client_certificate.public_key_algorithm", + "type": "alias" + }, + "public_key_size": { + "path": "tls.detailed.client_certificate.public_key_size", + "type": "alias" + }, + "serial_number": { + "path": "tls.detailed.client_certificate.serial_number", + "type": "alias" + }, + "signature_algorithm": { + "path": "tls.detailed.client_certificate.signature_algorithm", + "type": "alias" + }, + "subject": { + "properties": { + "common_name": { + "path": "tls.detailed.client_certificate.subject.common_name", + "type": "alias" + }, + "country": { + "path": "tls.detailed.client_certificate.subject.country", + "type": "alias" + }, + "locality": { + "path": "tls.detailed.client_certificate.subject.locality", + "type": "alias" + }, + "organization": { + "path": "tls.detailed.client_certificate.subject.organization", + "type": "alias" + }, + "organizational_unit": { + "path": "tls.detailed.client_certificate.subject.organizational_unit", + "type": "alias" + }, + "province": { + "path": "tls.detailed.client_certificate.subject.province", + "type": "alias" + } + } + }, + "version": { + "path": "tls.detailed.client_certificate.version", + "type": "alias" + } + } + }, + "client_certificate_requested": { + "path": "tls.detailed.client_certificate_requested", + "type": "alias" + }, + "client_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "path": "tls.detailed.client_hello.extensions._unparsed_", + "type": "alias" + }, + "application_layer_protocol_negotiation": { + "path": "tls.detailed.client_hello.extensions.application_layer_protocol_negotiation", + "type": "alias" + }, + "ec_points_formats": { + "path": "tls.detailed.client_hello.extensions.ec_points_formats", + "type": "alias" + }, + "server_name_indication": { + "path": "tls.detailed.client_hello.extensions.server_name_indication", + "type": "alias" + }, + "session_ticket": { + "path": "tls.detailed.client_hello.extensions.session_ticket", + "type": "alias" + }, + "signature_algorithms": { + "path": "tls.detailed.client_hello.extensions.signature_algorithms", + "type": "alias" + }, + "supported_groups": { + "path": "tls.detailed.client_hello.extensions.supported_groups", + "type": "alias" + }, + "supported_versions": { + "path": "tls.detailed.client_hello.extensions.supported_versions", + "type": "alias" + } + } + }, + "session_id": { + "path": "tls.detailed.client_hello.session_id", + "type": "alias" + }, + "supported_ciphers": { + "path": "tls.client.supported_ciphers", + "type": "alias" + }, + "supported_compression_methods": { + "path": "tls.detailed.client_hello.supported_compression_methods", + "type": "alias" + }, + "version": { + "path": "tls.detailed.client_hello.version", + "type": "alias" + } + } + }, + "curve": { + "ignore_above": 1024, + "type": "keyword" + }, + "detailed": { + "properties": { + "alert_types": { + "ignore_above": 1024, + "type": "keyword" + }, + "client_certificate": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "client_certificate_requested": { + "type": "boolean" + }, + "client_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "ignore_above": 1024, + "type": "keyword" + }, + "application_layer_protocol_negotiation": { + "ignore_above": 1024, + "type": "keyword" + }, + "ec_points_formats": { + "ignore_above": 1024, + "type": "keyword" + }, + "server_name_indication": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_ticket": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithms": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_groups": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_versions": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "session_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_compression_methods": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resumption_method": { + "ignore_above": 1024, + "type": "keyword" + }, + "server_certificate": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "server_certificate_chain": { + "properties": { + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_before": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "server_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "ignore_above": 1024, + "type": "keyword" + }, + "application_layer_protocol_negotiation": { + "ignore_above": 1024, + "type": "keyword" + }, + "ec_points_formats": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_ticket": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_versions": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "selected_compression_method": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "established": { + "type": "boolean" + }, + "fingerprints": { + "properties": { + "ja3": { + "path": "tls.client.ja3", + "type": "alias" + } + } + }, + "handshake_completed": { + "path": "tls.established", + "type": "alias" + }, + "next_protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "resumed": { + "type": "boolean" + }, + "resumption_method": { + "path": "tls.detailed.resumption_method", + "type": "alias" + }, + "server": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3s": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server_certificate": { + "properties": { + "alternative_names": { + "path": "tls.detailed.server_certificate.alternative_names", + "type": "alias" + }, + "issuer": { + "properties": { + "common_name": { + "path": "tls.detailed.server_certificate.issuer.common_name", + "type": "alias" + }, + "country": { + "path": "tls.detailed.server_certificate.issuer.country", + "type": "alias" + }, + "locality": { + "path": "tls.detailed.server_certificate.issuer.locality", + "type": "alias" + }, + "organization": { + "path": "tls.detailed.server_certificate.issuer.organization", + "type": "alias" + }, + "organizational_unit": { + "path": "tls.detailed.server_certificate.issuer.organizational_unit", + "type": "alias" + }, + "province": { + "path": "tls.detailed.server_certificate.issuer.province", + "type": "alias" + } + } + }, + "not_after": { + "path": "tls.detailed.server_certificate.not_after", + "type": "alias" + }, + "not_before": { + "path": "tls.detailed.server_certificate.not_before", + "type": "alias" + }, + "public_key_algorithm": { + "path": "tls.detailed.server_certificate.public_key_algorithm", + "type": "alias" + }, + "public_key_size": { + "path": "tls.detailed.server_certificate.public_key_size", + "type": "alias" + }, + "serial_number": { + "path": "tls.detailed.server_certificate.serial_number", + "type": "alias" + }, + "signature_algorithm": { + "path": "tls.detailed.server_certificate.signature_algorithm", + "type": "alias" + }, + "subject": { + "properties": { + "common_name": { + "path": "tls.detailed.server_certificate.subject.common_name", + "type": "alias" + }, + "country": { + "path": "tls.detailed.server_certificate.subject.country", + "type": "alias" + }, + "locality": { + "path": "tls.detailed.server_certificate.subject.locality", + "type": "alias" + }, + "organization": { + "path": "tls.detailed.server_certificate.subject.organization", + "type": "alias" + }, + "organizational_unit": { + "path": "tls.detailed.server_certificate.subject.organizational_unit", + "type": "alias" + }, + "province": { + "path": "tls.detailed.server_certificate.subject.province", + "type": "alias" + } + } + }, + "version": { + "path": "tls.detailed.server_certificate.version", + "type": "alias" + } + } + }, + "server_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "path": "tls.detailed.server_hello.extensions._unparsed_", + "type": "alias" + }, + "application_layer_protocol_negotiation": { + "path": "tls.detailed.server_hello.extensions.application_layer_protocol_negotiation", + "type": "alias" + }, + "ec_points_formats": { + "path": "tls.detailed.server_hello.extensions.ec_points_formats", + "type": "alias" + }, + "session_ticket": { + "path": "tls.detailed.server_hello.extensions.session_ticket", + "type": "alias" + }, + "supported_versions": { + "path": "tls.detailed.server_hello.extensions.supported_versions", + "type": "alias" + } + } + }, + "selected_cipher": { + "path": "tls.cipher", + "type": "alias" + }, + "selected_compression_method": { + "path": "tls.detailed.server_hello.selected_compression_method", + "type": "alias" + }, + "session_id": { + "path": "tls.detailed.server_hello.session_id", + "type": "alias" + }, + "version": { + "path": "tls.detailed.server_hello.version", + "type": "alias" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + }, + "version_protocol": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tracing": { + "properties": { + "trace": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "transaction": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "url": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "fragment": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "password": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "scheme": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "username": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vulnerability": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "classification": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "enumeration": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "report_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "scanner": { + "properties": { + "vendor": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "score": { + "properties": { + "base": { + "type": "float" + }, + "environmental": { + "type": "float" + }, + "temporal": { + "type": "float" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "severity": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "lifecycle": { + "indexing_complete": "true", + "name": "packetbeat", + "rollover_alias": "packetbeat-7.6.0" + }, + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "number_of_replicas": "1", + "number_of_shards": "1", + "query": { + "default_field": [ + "message", + "tags", + "agent.ephemeral_id", + "agent.id", + "agent.name", + "agent.type", + "agent.version", + "as.organization.name", + "client.address", + "client.as.organization.name", + "client.domain", + "client.geo.city_name", + "client.geo.continent_name", + "client.geo.country_iso_code", + "client.geo.country_name", + "client.geo.name", + "client.geo.region_iso_code", + "client.geo.region_name", + "client.mac", + "client.registered_domain", + "client.top_level_domain", + "client.user.domain", + "client.user.email", + "client.user.full_name", + "client.user.group.domain", + "client.user.group.id", + "client.user.group.name", + "client.user.hash", + "client.user.id", + "client.user.name", + "cloud.account.id", + "cloud.availability_zone", + "cloud.instance.id", + "cloud.instance.name", + "cloud.machine.type", + "cloud.provider", + "cloud.region", + "container.id", + "container.image.name", + "container.image.tag", + "container.name", + "container.runtime", + "destination.address", + "destination.as.organization.name", + "destination.domain", + "destination.geo.city_name", + "destination.geo.continent_name", + "destination.geo.country_iso_code", + "destination.geo.country_name", + "destination.geo.name", + "destination.geo.region_iso_code", + "destination.geo.region_name", + "destination.mac", + "destination.registered_domain", + "destination.top_level_domain", + "destination.user.domain", + "destination.user.email", + "destination.user.full_name", + "destination.user.group.domain", + "destination.user.group.id", + "destination.user.group.name", + "destination.user.hash", + "destination.user.id", + "destination.user.name", + "dns.answers.class", + "dns.answers.data", + "dns.answers.name", + "dns.answers.type", + "dns.header_flags", + "dns.id", + "dns.op_code", + "dns.question.class", + "dns.question.name", + "dns.question.registered_domain", + "dns.question.subdomain", + "dns.question.top_level_domain", + "dns.question.type", + "dns.response_code", + "dns.type", + "ecs.version", + "error.code", + "error.id", + "error.message", + "error.stack_trace", + "error.type", + "event.action", + "event.category", + "event.code", + "event.dataset", + "event.hash", + "event.id", + "event.kind", + "event.module", + "event.original", + "event.outcome", + "event.provider", + "event.timezone", + "event.type", + "file.device", + "file.directory", + "file.extension", + "file.gid", + "file.group", + "file.hash.md5", + "file.hash.sha1", + "file.hash.sha256", + "file.hash.sha512", + "file.inode", + "file.mode", + "file.name", + "file.owner", + "file.path", + "file.target_path", + "file.type", + "file.uid", + "geo.city_name", + "geo.continent_name", + "geo.country_iso_code", + "geo.country_name", + "geo.name", + "geo.region_iso_code", + "geo.region_name", + "group.domain", + "group.id", + "group.name", + "hash.md5", + "hash.sha1", + "hash.sha256", + "hash.sha512", + "host.architecture", + "host.geo.city_name", + "host.geo.continent_name", + "host.geo.country_iso_code", + "host.geo.country_name", + "host.geo.name", + "host.geo.region_iso_code", + "host.geo.region_name", + "host.hostname", + "host.id", + "host.mac", + "host.name", + "host.os.family", + "host.os.full", + "host.os.kernel", + "host.os.name", + "host.os.platform", + "host.os.version", + "host.type", + "host.user.domain", + "host.user.email", + "host.user.full_name", + "host.user.group.domain", + "host.user.group.id", + "host.user.group.name", + "host.user.hash", + "host.user.id", + "host.user.name", + "http.request.body.content", + "http.request.method", + "http.request.referrer", + "http.response.body.content", + "http.version", + "log.level", + "log.logger", + "log.origin.file.name", + "log.origin.function", + "log.original", + "log.syslog.facility.name", + "log.syslog.severity.name", + "network.application", + "network.community_id", + "network.direction", + "network.iana_number", + "network.name", + "network.protocol", + "network.transport", + "network.type", + "observer.geo.city_name", + "observer.geo.continent_name", + "observer.geo.country_iso_code", + "observer.geo.country_name", + "observer.geo.name", + "observer.geo.region_iso_code", + "observer.geo.region_name", + "observer.hostname", + "observer.mac", + "observer.name", + "observer.os.family", + "observer.os.full", + "observer.os.kernel", + "observer.os.name", + "observer.os.platform", + "observer.os.version", + "observer.product", + "observer.serial_number", + "observer.type", + "observer.vendor", + "observer.version", + "organization.id", + "organization.name", + "os.family", + "os.full", + "os.kernel", + "os.name", + "os.platform", + "os.version", + "package.architecture", + "package.checksum", + "package.description", + "package.install_scope", + "package.license", + "package.name", + "package.path", + "package.version", + "process.args", + "text", + "process.executable", + "process.hash.md5", + "process.hash.sha1", + "process.hash.sha256", + "process.hash.sha512", + "process.name", + "text", + "text", + "text", + "text", + "text", + "process.thread.name", + "process.title", + "process.working_directory", + "server.address", + "server.as.organization.name", + "server.domain", + "server.geo.city_name", + "server.geo.continent_name", + "server.geo.country_iso_code", + "server.geo.country_name", + "server.geo.name", + "server.geo.region_iso_code", + "server.geo.region_name", + "server.mac", + "server.registered_domain", + "server.top_level_domain", + "server.user.domain", + "server.user.email", + "server.user.full_name", + "server.user.group.domain", + "server.user.group.id", + "server.user.group.name", + "server.user.hash", + "server.user.id", + "server.user.name", + "service.ephemeral_id", + "service.id", + "service.name", + "service.node.name", + "service.state", + "service.type", + "service.version", + "source.address", + "source.as.organization.name", + "source.domain", + "source.geo.city_name", + "source.geo.continent_name", + "source.geo.country_iso_code", + "source.geo.country_name", + "source.geo.name", + "source.geo.region_iso_code", + "source.geo.region_name", + "source.mac", + "source.registered_domain", + "source.top_level_domain", + "source.user.domain", + "source.user.email", + "source.user.full_name", + "source.user.group.domain", + "source.user.group.id", + "source.user.group.name", + "source.user.hash", + "source.user.id", + "source.user.name", + "threat.framework", + "threat.tactic.id", + "threat.tactic.name", + "threat.tactic.reference", + "threat.technique.id", + "threat.technique.name", + "threat.technique.reference", + "tracing.trace.id", + "tracing.transaction.id", + "url.domain", + "url.extension", + "url.fragment", + "url.full", + "url.original", + "url.password", + "url.path", + "url.query", + "url.registered_domain", + "url.scheme", + "url.top_level_domain", + "url.username", + "user.domain", + "user.email", + "user.full_name", + "user.group.domain", + "user.group.id", + "user.group.name", + "user.hash", + "user.id", + "user.name", + "user_agent.device.name", + "user_agent.name", + "text", + "user_agent.original", + "user_agent.os.family", + "user_agent.os.full", + "user_agent.os.kernel", + "user_agent.os.name", + "user_agent.os.platform", + "user_agent.os.version", + "user_agent.version", + "text", + "agent.hostname", + "timeseries.instance", + "cloud.project.id", + "cloud.image.id", + "host.os.build", + "host.os.codename", + "kubernetes.pod.name", + "kubernetes.pod.uid", + "kubernetes.namespace", + "kubernetes.node.name", + "kubernetes.replicaset.name", + "kubernetes.deployment.name", + "kubernetes.statefulset.name", + "kubernetes.container.name", + "kubernetes.container.image", + "jolokia.agent.version", + "jolokia.agent.id", + "jolokia.server.product", + "jolokia.server.version", + "jolokia.server.vendor", + "jolokia.url", + "type", + "server.process.name", + "server.process.args", + "server.process.executable", + "server.process.working_directory", + "server.process.start", + "client.process.name", + "client.process.args", + "client.process.executable", + "client.process.working_directory", + "client.process.start", + "flow.id", + "status", + "method", + "resource", + "path", + "query", + "params", + "request", + "response", + "amqp.reply-text", + "amqp.exchange", + "amqp.exchange-type", + "amqp.consumer-tag", + "amqp.routing-key", + "amqp.queue", + "amqp.content-type", + "amqp.content-encoding", + "amqp.delivery-mode", + "amqp.correlation-id", + "amqp.reply-to", + "amqp.expiration", + "amqp.message-id", + "amqp.timestamp", + "amqp.type", + "amqp.user-id", + "amqp.app-id", + "cassandra.request.headers.flags", + "cassandra.request.headers.stream", + "cassandra.request.headers.op", + "cassandra.request.query", + "cassandra.response.headers.flags", + "cassandra.response.headers.stream", + "cassandra.response.headers.op", + "cassandra.response.result.type", + "cassandra.response.result.rows.meta.keyspace", + "cassandra.response.result.rows.meta.table", + "cassandra.response.result.rows.meta.flags", + "cassandra.response.result.rows.meta.paging_state", + "cassandra.response.result.keyspace", + "cassandra.response.result.schema_change.change", + "cassandra.response.result.schema_change.keyspace", + "cassandra.response.result.schema_change.table", + "cassandra.response.result.schema_change.object", + "cassandra.response.result.schema_change.target", + "cassandra.response.result.schema_change.name", + "cassandra.response.result.schema_change.args", + "cassandra.response.result.prepared.prepared_id", + "cassandra.response.result.prepared.req_meta.keyspace", + "cassandra.response.result.prepared.req_meta.table", + "cassandra.response.result.prepared.req_meta.flags", + "cassandra.response.result.prepared.req_meta.paging_state", + "cassandra.response.result.prepared.resp_meta.keyspace", + "cassandra.response.result.prepared.resp_meta.table", + "cassandra.response.result.prepared.resp_meta.flags", + "cassandra.response.result.prepared.resp_meta.paging_state", + "cassandra.response.authentication.class", + "cassandra.response.warnings", + "cassandra.response.event.type", + "cassandra.response.event.change", + "cassandra.response.event.host", + "cassandra.response.event.schema_change.change", + "cassandra.response.event.schema_change.keyspace", + "cassandra.response.event.schema_change.table", + "cassandra.response.event.schema_change.object", + "cassandra.response.event.schema_change.target", + "cassandra.response.event.schema_change.name", + "cassandra.response.event.schema_change.args", + "cassandra.response.error.msg", + "cassandra.response.error.type", + "cassandra.response.error.details.read_consistency", + "cassandra.response.error.details.write_type", + "cassandra.response.error.details.keyspace", + "cassandra.response.error.details.table", + "cassandra.response.error.details.stmt_id", + "cassandra.response.error.details.num_failures", + "cassandra.response.error.details.function", + "cassandra.response.error.details.arg_types", + "dhcpv4.transaction_id", + "dhcpv4.flags", + "dhcpv4.client_mac", + "dhcpv4.server_name", + "dhcpv4.op_code", + "dhcpv4.hardware_type", + "dhcpv4.option.message_type", + "dhcpv4.option.parameter_request_list", + "dhcpv4.option.class_identifier", + "dhcpv4.option.domain_name", + "dhcpv4.option.hostname", + "dhcpv4.option.message", + "dhcpv4.option.boot_file_name", + "dns.question.etld_plus_one", + "dns.authorities.name", + "dns.authorities.type", + "dns.authorities.class", + "dns.additionals.name", + "dns.additionals.type", + "dns.additionals.class", + "dns.additionals.data", + "dns.opt.version", + "dns.opt.ext_rcode", + "http.response.status_phrase", + "icmp.version", + "icmp.request.message", + "icmp.response.message", + "memcache.protocol_type", + "memcache.request.line", + "memcache.request.command", + "memcache.response.command", + "memcache.request.type", + "memcache.response.type", + "memcache.response.error_msg", + "memcache.request.opcode", + "memcache.response.opcode", + "memcache.response.status", + "memcache.request.raw_args", + "memcache.request.automove", + "memcache.response.version", + "mongodb.error", + "mongodb.fullCollectionName", + "mongodb.startingFrom", + "mongodb.query", + "mongodb.returnFieldsSelector", + "mongodb.selector", + "mongodb.update", + "mongodb.cursorId", + "mysql.insert_id", + "mysql.num_fields", + "mysql.num_rows", + "mysql.query", + "mysql.error_message", + "nfs.tag", + "nfs.opcode", + "nfs.status", + "rpc.xid", + "rpc.status", + "rpc.auth_flavor", + "rpc.cred.gids", + "rpc.cred.machinename", + "pgsql.error_message", + "pgsql.error_severity", + "pgsql.num_fields", + "pgsql.num_rows", + "redis.return_value", + "redis.error", + "thrift.params", + "thrift.service", + "thrift.return_value", + "thrift.exceptions", + "tls.detailed.version", + "tls.detailed.resumption_method", + "tls.detailed.client_hello.version", + "tls.detailed.client_hello.session_id", + "tls.detailed.client_hello.supported_compression_methods", + "tls.detailed.client_hello.extensions.server_name_indication", + "tls.detailed.client_hello.extensions.application_layer_protocol_negotiation", + "tls.detailed.client_hello.extensions.session_ticket", + "tls.detailed.client_hello.extensions.supported_versions", + "tls.detailed.client_hello.extensions.supported_groups", + "tls.detailed.client_hello.extensions.signature_algorithms", + "tls.detailed.client_hello.extensions.ec_points_formats", + "tls.detailed.client_hello.extensions._unparsed_", + "tls.detailed.server_hello.version", + "tls.detailed.server_hello.selected_compression_method", + "tls.detailed.server_hello.session_id", + "tls.detailed.server_hello.extensions.application_layer_protocol_negotiation", + "tls.detailed.server_hello.extensions.session_ticket", + "tls.detailed.server_hello.extensions.supported_versions", + "tls.detailed.server_hello.extensions.ec_points_formats", + "tls.detailed.server_hello.extensions._unparsed_", + "tls.detailed.client_certificate.serial_number", + "tls.detailed.client_certificate.public_key_algorithm", + "tls.detailed.client_certificate.signature_algorithm", + "tls.detailed.client_certificate.alternative_names", + "tls.detailed.client_certificate.subject.country", + "tls.detailed.client_certificate.subject.organization", + "tls.detailed.client_certificate.subject.organizational_unit", + "tls.detailed.client_certificate.subject.province", + "tls.detailed.client_certificate.subject.common_name", + "tls.detailed.client_certificate.subject.locality", + "tls.detailed.client_certificate.issuer.country", + "tls.detailed.client_certificate.issuer.organization", + "tls.detailed.client_certificate.issuer.organizational_unit", + "tls.detailed.client_certificate.issuer.province", + "tls.detailed.client_certificate.issuer.common_name", + "tls.detailed.client_certificate.issuer.locality", + "tls.detailed.server_certificate.serial_number", + "tls.detailed.server_certificate.public_key_algorithm", + "tls.detailed.server_certificate.signature_algorithm", + "tls.detailed.server_certificate.alternative_names", + "tls.detailed.server_certificate.subject.country", + "tls.detailed.server_certificate.subject.organization", + "tls.detailed.server_certificate.subject.organizational_unit", + "tls.detailed.server_certificate.subject.province", + "tls.detailed.server_certificate.subject.common_name", + "tls.detailed.server_certificate.subject.locality", + "tls.detailed.server_certificate.issuer.country", + "tls.detailed.server_certificate.issuer.organization", + "tls.detailed.server_certificate.issuer.organizational_unit", + "tls.detailed.server_certificate.issuer.province", + "tls.detailed.server_certificate.issuer.common_name", + "tls.detailed.server_certificate.issuer.locality", + "tls.detailed.alert_types", + "fields.*" + ] + }, + "refresh_interval": "5s" + } + } + } +} + +{ + "type": "index", + "value": { + "aliases": { + "beats": { + }, + "packetbeat-8.0.0": { + "is_write_index": true + }, + "packetbeat-tls": { + "filter": { + "term": { + "event.dataset": "tls" + } + } + }, + "siem-read-alias": { + } + }, + "index": "packetbeat-8.0.0-2019.08.29-000010", + "mappings": { + "_meta": { + "beat": "packetbeat", + "version": "8.0.0" + }, + "date_detection": false, + "dynamic_templates": [ + { + "labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "labels.*" + } + }, + { + "container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "container.labels.*" + } + }, + { + "dns.answers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "dns.answers.*" + } + }, + { + "fields": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "fields.*" + } + }, + { + "docker.container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "docker.container.labels.*" + } + }, + { + "kubernetes.labels.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.labels.*" + } + }, + { + "kubernetes.annotations.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.annotations.*" + } + }, + { + "amqp.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "amqp.headers.*" + } + }, + { + "cassandra.response.supported": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "cassandra.response.supported.*" + } + }, + { + "http.request.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "http.request.headers.*" + } + }, + { + "http.response.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "http.response.headers.*" + } + }, + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "properties": { + "@timestamp": { + "type": "date" + }, + "@version": { + "ignore_above": 1024, + "type": "keyword" + }, + "agent": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "amqp": { + "properties": { + "app-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "arguments": { + "type": "object" + }, + "auto-delete": { + "type": "boolean" + }, + "class-id": { + "type": "long" + }, + "consumer-count": { + "type": "long" + }, + "consumer-tag": { + "ignore_above": 1024, + "type": "keyword" + }, + "content-encoding": { + "ignore_above": 1024, + "type": "keyword" + }, + "content-type": { + "ignore_above": 1024, + "type": "keyword" + }, + "correlation-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "delivery-mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "delivery-tag": { + "type": "long" + }, + "durable": { + "type": "boolean" + }, + "exchange": { + "ignore_above": 1024, + "type": "keyword" + }, + "exchange-type": { + "ignore_above": 1024, + "type": "keyword" + }, + "exclusive": { + "type": "boolean" + }, + "expiration": { + "ignore_above": 1024, + "type": "keyword" + }, + "headers": { + "type": "object" + }, + "if-empty": { + "type": "boolean" + }, + "if-unused": { + "type": "boolean" + }, + "immediate": { + "type": "boolean" + }, + "mandatory": { + "type": "boolean" + }, + "message-count": { + "type": "long" + }, + "message-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "method-id": { + "type": "long" + }, + "multiple": { + "type": "boolean" + }, + "no-ack": { + "type": "boolean" + }, + "no-local": { + "type": "boolean" + }, + "no-wait": { + "type": "boolean" + }, + "passive": { + "type": "boolean" + }, + "priority": { + "type": "long" + }, + "queue": { + "ignore_above": 1024, + "type": "keyword" + }, + "redelivered": { + "type": "boolean" + }, + "reply-code": { + "type": "long" + }, + "reply-text": { + "ignore_above": 1024, + "type": "keyword" + }, + "reply-to": { + "ignore_above": 1024, + "type": "keyword" + }, + "routing-key": { + "ignore_above": 1024, + "type": "keyword" + }, + "timestamp": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "user-id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes_in": { + "path": "source.bytes", + "type": "alias" + }, + "bytes_out": { + "path": "destination.bytes", + "type": "alias" + }, + "cassandra": { + "properties": { + "no_request": { + "type": "boolean" + }, + "request": { + "properties": { + "headers": { + "properties": { + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "length": { + "type": "long" + }, + "op": { + "ignore_above": 1024, + "type": "keyword" + }, + "stream": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "authentication": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "type": "long" + }, + "details": { + "properties": { + "alive": { + "type": "long" + }, + "arg_types": { + "ignore_above": 1024, + "type": "keyword" + }, + "blockfor": { + "type": "long" + }, + "data_present": { + "type": "boolean" + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_failures": { + "ignore_above": 1024, + "type": "keyword" + }, + "read_consistency": { + "ignore_above": 1024, + "type": "keyword" + }, + "received": { + "type": "long" + }, + "required": { + "type": "long" + }, + "stmt_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "write_type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "msg": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "host": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "schema_change": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "object": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "target": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "headers": { + "properties": { + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "length": { + "type": "long" + }, + "op": { + "ignore_above": 1024, + "type": "keyword" + }, + "stream": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "result": { + "properties": { + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "prepared": { + "properties": { + "prepared_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "req_meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resp_meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "rows": { + "properties": { + "meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "num_rows": { + "type": "long" + } + } + }, + "schema_change": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "object": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "target": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "supported": { + "type": "object" + }, + "warnings": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "client": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "start": { + "ignore_above": 1024, + "type": "keyword" + }, + "working_directory": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "availability_zone": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "instance": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "machine": { + "properties": { + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "project": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "container": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "runtime": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "domain_top1m_rank": { + "type": "long" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dhcpv4": { + "properties": { + "assigned_ip": { + "type": "ip" + }, + "client_ip": { + "type": "ip" + }, + "client_mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "hardware_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "hops": { + "type": "long" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "option": { + "properties": { + "boot_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "broadcast_address": { + "type": "ip" + }, + "class_identifier": { + "ignore_above": 1024, + "type": "keyword" + }, + "dns_servers": { + "type": "ip" + }, + "domain_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip_address_lease_time_sec": { + "type": "long" + }, + "max_dhcp_message_size": { + "type": "long" + }, + "message": { + "norms": false, + "type": "text" + }, + "message_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "ntp_servers": { + "type": "ip" + }, + "parameter_request_list": { + "ignore_above": 1024, + "type": "keyword" + }, + "rebinding_time_sec": { + "type": "long" + }, + "renewal_time_sec": { + "type": "long" + }, + "requested_ip_address": { + "type": "ip" + }, + "router": { + "type": "ip" + }, + "server_identifier": { + "type": "ip" + }, + "subnet_mask": { + "type": "ip" + }, + "time_servers": { + "type": "ip" + }, + "utc_time_offset_sec": { + "type": "long" + }, + "vendor_identifying_options": { + "type": "object" + } + } + }, + "relay_ip": { + "type": "ip" + }, + "seconds": { + "type": "long" + }, + "server_ip": { + "type": "ip" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "transaction_id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "dns": { + "properties": { + "additionals": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "additionals_count": { + "type": "long" + }, + "answers": { + "properties": { + "algorithm": { + "type": "keyword" + }, + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "digest_type": { + "type": "keyword" + }, + "expiration": { + "type": "keyword" + }, + "expire": { + "type": "long" + }, + "flags": { + "type": "keyword" + }, + "inception": { + "type": "keyword" + }, + "key_tag": { + "type": "keyword" + }, + "labels": { + "type": "keyword" + }, + "minimum": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_ttl": { + "type": "keyword" + }, + "protocol": { + "type": "keyword" + }, + "refresh": { + "type": "long" + }, + "retry": { + "type": "long" + }, + "rname": { + "type": "keyword" + }, + "serial": { + "type": "long" + }, + "signer_name": { + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "type_covered": { + "type": "keyword" + } + } + }, + "answers_count": { + "type": "long" + }, + "authorities": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "expire": { + "type": "long" + }, + "minimum": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "refresh": { + "type": "long" + }, + "retry": { + "type": "long" + }, + "rname": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial": { + "type": "long" + }, + "ttl": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "authorities_count": { + "type": "long" + }, + "flags": { + "properties": { + "authentic_data": { + "type": "boolean" + }, + "authoritative": { + "type": "boolean" + }, + "checking_disabled": { + "type": "boolean" + }, + "recursion_available": { + "type": "boolean" + }, + "recursion_desired": { + "type": "boolean" + }, + "truncated_response": { + "type": "boolean" + } + } + }, + "header_flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "opt": { + "properties": { + "do": { + "type": "boolean" + }, + "ext_rcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "udp_size": { + "type": "long" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "question": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "etld_plus_one": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resolved_ip": { + "type": "ip" + }, + "response_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "docker": { + "properties": { + "container": { + "properties": { + "labels": { + "type": "object" + } + } + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "message": { + "norms": false, + "type": "text" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "outcome": { + "ignore_above": 1024, + "type": "keyword" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "fields": { + "type": "object" + }, + "file": { + "properties": { + "accessed": { + "type": "date" + }, + "created": { + "type": "date" + }, + "ctime": { + "type": "date" + }, + "device": { + "ignore_above": 1024, + "type": "keyword" + }, + "directory": { + "ignore_above": 1024, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "inode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mtime": { + "type": "date" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "owner": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "target_path": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "flow": { + "properties": { + "final": { + "type": "boolean" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "vlan": { + "type": "long" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "containerized": { + "type": "boolean" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "build": { + "ignore_above": 1024, + "type": "keyword" + }, + "codename": { + "ignore_above": 1024, + "type": "keyword" + }, + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "headers": { + "properties": { + "content-length": { + "type": "long" + }, + "content-type": { + "type": "keyword" + }, + "host": { + "type": "keyword" + }, + "referer": { + "type": "keyword" + }, + "user-agent": { + "type": "keyword" + } + } + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "referrer": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "headers": { + "properties": { + "content-length": { + "type": "long" + }, + "content-type": { + "type": "keyword" + }, + "host": { + "type": "keyword" + }, + "location": { + "type": "keyword" + }, + "user-agent": { + "type": "keyword" + } + } + }, + "status_code": { + "type": "long" + }, + "status_phrase": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "icmp": { + "properties": { + "request": { + "properties": { + "code": { + "type": "long" + }, + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "type": "long" + } + } + }, + "response": { + "properties": { + "code": { + "type": "long" + }, + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "type": "long" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "jolokia": { + "properties": { + "agent": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "secured": { + "type": "boolean" + }, + "server": { + "properties": { + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "kubernetes": { + "properties": { + "annotations": { + "properties": { + "*": { + "type": "object" + } + } + }, + "container": { + "properties": { + "image": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "deployment": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "properties": { + "*": { + "type": "object" + } + } + }, + "namespace": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pod": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "replicaset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "statefulset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "logger": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "memcache": { + "properties": { + "protocol_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "request": { + "properties": { + "automove": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "cas_unique": { + "type": "long" + }, + "command": { + "ignore_above": 1024, + "type": "keyword" + }, + "count_values": { + "type": "long" + }, + "delta": { + "type": "long" + }, + "dest_class": { + "type": "long" + }, + "exptime": { + "type": "long" + }, + "flags": { + "type": "long" + }, + "initial": { + "type": "long" + }, + "line": { + "ignore_above": 1024, + "type": "keyword" + }, + "noreply": { + "type": "boolean" + }, + "opaque": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "opcode_value": { + "type": "long" + }, + "quiet": { + "type": "boolean" + }, + "raw_args": { + "ignore_above": 1024, + "type": "keyword" + }, + "sleep_us": { + "type": "long" + }, + "source_class": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vbucket": { + "type": "long" + }, + "verbosity": { + "type": "long" + } + } + }, + "response": { + "properties": { + "bytes": { + "type": "long" + }, + "cas_unique": { + "type": "long" + }, + "command": { + "ignore_above": 1024, + "type": "keyword" + }, + "count_values": { + "type": "long" + }, + "error_msg": { + "ignore_above": 1024, + "type": "keyword" + }, + "flags": { + "type": "long" + }, + "opaque": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "opcode_value": { + "type": "long" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "status_code": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "type": "long" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "message": { + "norms": false, + "type": "text" + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "mongodb": { + "properties": { + "cursorId": { + "ignore_above": 1024, + "type": "keyword" + }, + "error": { + "ignore_above": 1024, + "type": "keyword" + }, + "fullCollectionName": { + "ignore_above": 1024, + "type": "keyword" + }, + "numberReturned": { + "type": "long" + }, + "numberToReturn": { + "type": "long" + }, + "numberToSkip": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "returnFieldsSelector": { + "ignore_above": 1024, + "type": "keyword" + }, + "selector": { + "ignore_above": 1024, + "type": "keyword" + }, + "startingFrom": { + "ignore_above": 1024, + "type": "keyword" + }, + "update": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "mysql": { + "properties": { + "affected_rows": { + "type": "long" + }, + "error_code": { + "type": "long" + }, + "error_message": { + "ignore_above": 1024, + "type": "keyword" + }, + "insert_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_fields": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_rows": { + "ignore_above": 1024, + "type": "keyword" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "network": { + "properties": { + "application": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "community_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "direction": { + "ignore_above": 1024, + "type": "keyword" + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "packets": { + "type": "long" + }, + "protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "transport": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "nfs": { + "properties": { + "minor_version": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "notes": { + "path": "error.message", + "type": "alias" + }, + "observer": { + "properties": { + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "organization": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "params": { + "norms": false, + "type": "text" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "pgsql": { + "properties": { + "error_code": { + "type": "long" + }, + "error_message": { + "ignore_above": 1024, + "type": "keyword" + }, + "error_severity": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_fields": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_rows": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "redis": { + "properties": { + "error": { + "ignore_above": 1024, + "type": "keyword" + }, + "return_value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "related": { + "properties": { + "ip": { + "type": "ip" + } + } + }, + "request": { + "norms": false, + "type": "text" + }, + "resource": { + "ignore_above": 1024, + "type": "keyword" + }, + "response": { + "norms": false, + "type": "text" + }, + "rpc": { + "properties": { + "auth_flavor": { + "ignore_above": 1024, + "type": "keyword" + }, + "cred": { + "properties": { + "gid": { + "type": "long" + }, + "gids": { + "ignore_above": 1024, + "type": "keyword" + }, + "machinename": { + "ignore_above": 1024, + "type": "keyword" + }, + "stamp": { + "type": "long" + }, + "uid": { + "type": "long" + } + } + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "xid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "start": { + "ignore_above": 1024, + "type": "keyword" + }, + "working_directory": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "source": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "domain_top1m_rank": { + "type": "long" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "tags": { + "ignore_above": 1024, + "type": "keyword" + }, + "thrift": { + "properties": { + "exceptions": { + "ignore_above": 1024, + "type": "keyword" + }, + "params": { + "ignore_above": 1024, + "type": "keyword" + }, + "return_value": { + "ignore_above": 1024, + "type": "keyword" + }, + "service": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "timeseries": { + "properties": { + "instance": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tls": { + "properties": { + "alert_types": { + "ignore_above": 1024, + "type": "keyword" + }, + "alerts": { + "properties": { + "code": { + "type": "long" + }, + "severity": { + "ignore_above": 1024, + "type": "keyword" + }, + "source": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "client_certificate": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "fingerprint": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "raw": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "client_certificate_chain": { + "properties": { + "fingerprint": { + "properties": { + "sha1": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_before": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "client_certificate_requested": { + "type": "boolean" + }, + "client_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "ignore_above": 1024, + "type": "keyword" + }, + "application_layer_protocol_negotiation": { + "ignore_above": 1024, + "type": "keyword" + }, + "ec_points_formats": { + "ignore_above": 1024, + "type": "keyword" + }, + "server_name_indication": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_ticket": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithms": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_groups": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_versions": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "session_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_ciphers": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_compression_methods": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "fingerprints": { + "properties": { + "ja3": { + "properties": { + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "str": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "handshake_completed": { + "type": "boolean" + }, + "resumed": { + "type": "boolean" + }, + "resumption_method": { + "ignore_above": 1024, + "type": "keyword" + }, + "server_certificate": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "fingerprint": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "raw": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "postal_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "street_address": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "server_certificate_chain": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "fingerprint": { + "properties": { + "sha1": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_before": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "server_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "ignore_above": 1024, + "type": "keyword" + }, + "application_layer_protocol_negotiation": { + "ignore_above": 1024, + "type": "keyword" + }, + "ec_points_formats": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_ticket": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_versions": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "selected_cipher": { + "ignore_above": 1024, + "type": "keyword" + }, + "selected_compression_method": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tracing": { + "properties": { + "trace": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "transaction": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "url": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "fragment": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "password": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "scheme": { + "ignore_above": 1024, + "type": "keyword" + }, + "username": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "codec": "best_compression", + "lifecycle": { + "name": "packetbeat-8.0.0", + "rollover_alias": "packetbeat-8.0.0" + }, + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "number_of_replicas": "1", + "number_of_shards": "1", + "query": { + "default_field": [ + "message", + "tags", + "agent.ephemeral_id", + "agent.id", + "agent.name", + "agent.type", + "agent.version", + "as.organization.name", + "client.address", + "client.as.organization.name", + "client.domain", + "client.geo.city_name", + "client.geo.continent_name", + "client.geo.country_iso_code", + "client.geo.country_name", + "client.geo.name", + "client.geo.region_iso_code", + "client.geo.region_name", + "client.mac", + "client.user.domain", + "client.user.email", + "client.user.full_name", + "client.user.group.id", + "client.user.group.name", + "client.user.hash", + "client.user.id", + "client.user.name", + "cloud.account.id", + "cloud.availability_zone", + "cloud.instance.id", + "cloud.instance.name", + "cloud.machine.type", + "cloud.provider", + "cloud.region", + "container.id", + "container.image.name", + "container.image.tag", + "container.name", + "container.runtime", + "destination.address", + "destination.as.organization.name", + "destination.domain", + "destination.geo.city_name", + "destination.geo.continent_name", + "destination.geo.country_iso_code", + "destination.geo.country_name", + "destination.geo.name", + "destination.geo.region_iso_code", + "destination.geo.region_name", + "destination.mac", + "destination.user.domain", + "destination.user.email", + "destination.user.full_name", + "destination.user.group.id", + "destination.user.group.name", + "destination.user.hash", + "destination.user.id", + "destination.user.name", + "dns.answers.class", + "dns.answers.data", + "dns.answers.name", + "dns.answers.type", + "dns.header_flags", + "dns.id", + "dns.op_code", + "dns.question.class", + "dns.question.name", + "dns.question.registered_domain", + "dns.question.type", + "dns.response_code", + "dns.type", + "ecs.version", + "error.code", + "error.id", + "error.message", + "event.action", + "event.category", + "event.code", + "event.dataset", + "event.hash", + "event.id", + "event.kind", + "event.module", + "event.original", + "event.outcome", + "event.provider", + "event.timezone", + "event.type", + "file.device", + "file.directory", + "file.extension", + "file.gid", + "file.group", + "file.hash.md5", + "file.hash.sha1", + "file.hash.sha256", + "file.hash.sha512", + "file.inode", + "file.mode", + "file.name", + "file.owner", + "file.path", + "file.target_path", + "file.type", + "file.uid", + "geo.city_name", + "geo.continent_name", + "geo.country_iso_code", + "geo.country_name", + "geo.name", + "geo.region_iso_code", + "geo.region_name", + "group.id", + "group.name", + "hash.md5", + "hash.sha1", + "hash.sha256", + "hash.sha512", + "host.architecture", + "host.geo.city_name", + "host.geo.continent_name", + "host.geo.country_iso_code", + "host.geo.country_name", + "host.geo.name", + "host.geo.region_iso_code", + "host.geo.region_name", + "host.hostname", + "host.id", + "host.mac", + "host.name", + "host.os.family", + "host.os.full", + "host.os.kernel", + "host.os.name", + "host.os.platform", + "host.os.version", + "host.type", + "host.user.domain", + "host.user.email", + "host.user.full_name", + "host.user.group.id", + "host.user.group.name", + "host.user.hash", + "host.user.id", + "host.user.name", + "http.request.body.content", + "http.request.method", + "http.request.referrer", + "http.response.body.content", + "http.version", + "log.level", + "log.logger", + "log.original", + "network.application", + "network.community_id", + "network.direction", + "network.iana_number", + "network.name", + "network.protocol", + "network.transport", + "network.type", + "observer.geo.city_name", + "observer.geo.continent_name", + "observer.geo.country_iso_code", + "observer.geo.country_name", + "observer.geo.name", + "observer.geo.region_iso_code", + "observer.geo.region_name", + "observer.hostname", + "observer.mac", + "observer.os.family", + "observer.os.full", + "observer.os.kernel", + "observer.os.name", + "observer.os.platform", + "observer.os.version", + "observer.serial_number", + "observer.type", + "observer.vendor", + "observer.version", + "organization.id", + "organization.name", + "os.family", + "os.full", + "os.kernel", + "os.name", + "os.platform", + "os.version", + "process.args", + "process.executable", + "process.hash.md5", + "process.hash.sha1", + "process.hash.sha256", + "process.hash.sha512", + "process.name", + "process.thread.name", + "process.title", + "process.working_directory", + "server.address", + "server.as.organization.name", + "server.domain", + "server.geo.city_name", + "server.geo.continent_name", + "server.geo.country_iso_code", + "server.geo.country_name", + "server.geo.name", + "server.geo.region_iso_code", + "server.geo.region_name", + "server.mac", + "server.user.domain", + "server.user.email", + "server.user.full_name", + "server.user.group.id", + "server.user.group.name", + "server.user.hash", + "server.user.id", + "server.user.name", + "service.ephemeral_id", + "service.id", + "service.name", + "service.state", + "service.type", + "service.version", + "source.address", + "source.as.organization.name", + "source.domain", + "source.geo.city_name", + "source.geo.continent_name", + "source.geo.country_iso_code", + "source.geo.country_name", + "source.geo.name", + "source.geo.region_iso_code", + "source.geo.region_name", + "source.mac", + "source.user.domain", + "source.user.email", + "source.user.full_name", + "source.user.group.id", + "source.user.group.name", + "source.user.hash", + "source.user.id", + "source.user.name", + "tracing.trace.id", + "tracing.transaction.id", + "url.domain", + "url.fragment", + "url.full", + "url.original", + "url.password", + "url.path", + "url.query", + "url.scheme", + "url.username", + "user.domain", + "user.email", + "user.full_name", + "user.group.id", + "user.group.name", + "user.hash", + "user.id", + "user.name", + "user_agent.device.name", + "user_agent.name", + "user_agent.original", + "user_agent.os.family", + "user_agent.os.full", + "user_agent.os.kernel", + "user_agent.os.name", + "user_agent.os.platform", + "user_agent.os.version", + "user_agent.version", + "agent.hostname", + "error.type", + "timeseries.instance", + "cloud.project.id", + "cloud.image.id", + "host.os.build", + "host.os.codename", + "kubernetes.pod.name", + "kubernetes.pod.uid", + "kubernetes.namespace", + "kubernetes.node.name", + "kubernetes.replicaset.name", + "kubernetes.deployment.name", + "kubernetes.statefulset.name", + "kubernetes.container.name", + "kubernetes.container.image", + "jolokia.agent.version", + "jolokia.agent.id", + "jolokia.server.product", + "jolokia.server.version", + "jolokia.server.vendor", + "jolokia.url", + "type", + "server.process.name", + "server.process.args", + "server.process.executable", + "server.process.working_directory", + "server.process.start", + "client.process.name", + "client.process.args", + "client.process.executable", + "client.process.working_directory", + "client.process.start", + "flow.id", + "status", + "method", + "resource", + "path", + "query", + "params", + "request", + "response", + "amqp.reply-text", + "amqp.exchange", + "amqp.exchange-type", + "amqp.consumer-tag", + "amqp.routing-key", + "amqp.queue", + "amqp.content-type", + "amqp.content-encoding", + "amqp.delivery-mode", + "amqp.correlation-id", + "amqp.reply-to", + "amqp.expiration", + "amqp.message-id", + "amqp.timestamp", + "amqp.type", + "amqp.user-id", + "amqp.app-id", + "cassandra.request.headers.flags", + "cassandra.request.headers.stream", + "cassandra.request.headers.op", + "cassandra.request.query", + "cassandra.response.headers.flags", + "cassandra.response.headers.stream", + "cassandra.response.headers.op", + "cassandra.response.result.type", + "cassandra.response.result.rows.meta.keyspace", + "cassandra.response.result.rows.meta.table", + "cassandra.response.result.rows.meta.flags", + "cassandra.response.result.rows.meta.paging_state", + "cassandra.response.result.keyspace", + "cassandra.response.result.schema_change.change", + "cassandra.response.result.schema_change.keyspace", + "cassandra.response.result.schema_change.table", + "cassandra.response.result.schema_change.object", + "cassandra.response.result.schema_change.target", + "cassandra.response.result.schema_change.name", + "cassandra.response.result.schema_change.args", + "cassandra.response.result.prepared.prepared_id", + "cassandra.response.result.prepared.req_meta.keyspace", + "cassandra.response.result.prepared.req_meta.table", + "cassandra.response.result.prepared.req_meta.flags", + "cassandra.response.result.prepared.req_meta.paging_state", + "cassandra.response.result.prepared.resp_meta.keyspace", + "cassandra.response.result.prepared.resp_meta.table", + "cassandra.response.result.prepared.resp_meta.flags", + "cassandra.response.result.prepared.resp_meta.paging_state", + "cassandra.response.authentication.class", + "cassandra.response.warnings", + "cassandra.response.event.type", + "cassandra.response.event.change", + "cassandra.response.event.host", + "cassandra.response.event.schema_change.change", + "cassandra.response.event.schema_change.keyspace", + "cassandra.response.event.schema_change.table", + "cassandra.response.event.schema_change.object", + "cassandra.response.event.schema_change.target", + "cassandra.response.event.schema_change.name", + "cassandra.response.event.schema_change.args", + "cassandra.response.error.msg", + "cassandra.response.error.type", + "cassandra.response.error.details.read_consistency", + "cassandra.response.error.details.write_type", + "cassandra.response.error.details.keyspace", + "cassandra.response.error.details.table", + "cassandra.response.error.details.stmt_id", + "cassandra.response.error.details.num_failures", + "cassandra.response.error.details.function", + "cassandra.response.error.details.arg_types", + "dhcpv4.transaction_id", + "dhcpv4.flags", + "dhcpv4.client_mac", + "dhcpv4.server_name", + "dhcpv4.op_code", + "dhcpv4.hardware_type", + "dhcpv4.option.message_type", + "dhcpv4.option.parameter_request_list", + "dhcpv4.option.class_identifier", + "dhcpv4.option.domain_name", + "dhcpv4.option.hostname", + "dhcpv4.option.message", + "dhcpv4.option.boot_file_name", + "dns.question.etld_plus_one", + "dns.authorities.name", + "dns.authorities.type", + "dns.authorities.class", + "dns.additionals.name", + "dns.additionals.type", + "dns.additionals.class", + "dns.additionals.data", + "dns.opt.version", + "dns.opt.ext_rcode", + "http.response.status_phrase", + "icmp.version", + "icmp.request.message", + "icmp.response.message", + "memcache.protocol_type", + "memcache.request.line", + "memcache.request.command", + "memcache.response.command", + "memcache.request.type", + "memcache.response.type", + "memcache.response.error_msg", + "memcache.request.opcode", + "memcache.response.opcode", + "memcache.response.status", + "memcache.request.raw_args", + "memcache.request.automove", + "memcache.response.version", + "mongodb.error", + "mongodb.fullCollectionName", + "mongodb.startingFrom", + "mongodb.query", + "mongodb.returnFieldsSelector", + "mongodb.selector", + "mongodb.update", + "mongodb.cursorId", + "mysql.insert_id", + "mysql.num_fields", + "mysql.num_rows", + "mysql.query", + "mysql.error_message", + "nfs.tag", + "nfs.opcode", + "nfs.status", + "rpc.xid", + "rpc.status", + "rpc.auth_flavor", + "rpc.cred.gids", + "rpc.cred.machinename", + "pgsql.error_message", + "pgsql.error_severity", + "pgsql.num_fields", + "pgsql.num_rows", + "redis.return_value", + "redis.error", + "thrift.params", + "thrift.service", + "thrift.return_value", + "thrift.exceptions", + "tls.version", + "tls.resumption_method", + "tls.client_hello.version", + "tls.client_hello.extensions.server_name_indication", + "tls.client_hello.extensions.application_layer_protocol_negotiation", + "tls.client_hello.extensions.session_ticket", + "tls.client_hello.extensions.supported_versions", + "tls.client_hello.extensions.supported_groups", + "tls.client_hello.extensions.signature_algorithms", + "tls.client_hello.extensions.ec_points_formats", + "tls.client_hello.extensions._unparsed_", + "tls.server_hello.version", + "tls.server_hello.selected_cipher", + "tls.server_hello.selected_compression_method", + "tls.server_hello.session_id", + "tls.server_hello.extensions.session_ticket", + "tls.server_hello.extensions.supported_versions", + "tls.server_hello.extensions.ec_points_formats", + "tls.server_hello.extensions._unparsed_", + "tls.client_certificate.serial_number", + "tls.client_certificate.public_key_algorithm", + "tls.client_certificate.signature_algorithm", + "tls.client_certificate.raw", + "tls.client_certificate.subject.country", + "tls.client_certificate.subject.organization", + "tls.client_certificate.subject.organizational_unit", + "tls.client_certificate.subject.province", + "tls.client_certificate.subject.common_name", + "tls.client_certificate.issuer.country", + "tls.client_certificate.issuer.organization", + "tls.client_certificate.issuer.organizational_unit", + "tls.client_certificate.issuer.province", + "tls.client_certificate.issuer.common_name", + "tls.client_certificate.fingerprint.md5", + "tls.client_certificate.fingerprint.sha1", + "tls.client_certificate.fingerprint.sha256", + "tls.server_certificate.serial_number", + "tls.server_certificate.public_key_algorithm", + "tls.server_certificate.signature_algorithm", + "tls.server_certificate.raw", + "tls.server_certificate.subject.country", + "tls.server_certificate.subject.organization", + "tls.server_certificate.subject.organizational_unit", + "tls.server_certificate.subject.province", + "tls.server_certificate.subject.common_name", + "tls.server_certificate.issuer.country", + "tls.server_certificate.issuer.organization", + "tls.server_certificate.issuer.organizational_unit", + "tls.server_certificate.issuer.province", + "tls.server_certificate.issuer.common_name", + "tls.server_certificate.fingerprint.md5", + "tls.server_certificate.fingerprint.sha1", + "tls.server_certificate.fingerprint.sha256", + "tls.alert_types", + "tls.fingerprints.ja3.hash", + "tls.fingerprints.ja3.str", + "fields.*" + ] + }, + "refresh_interval": "5s" + } + } + } +} \ No newline at end of file From 1a872752b76bcd66f517dc5afbbbd7e17fe5d081 Mon Sep 17 00:00:00 2001 From: Robert Oskamp Date: Tue, 24 Mar 2020 08:11:38 +0100 Subject: [PATCH 057/179] [ML] Functional tests - stabilize df analytics clone tests (#60497) This PR stabilizes the data frame analytics clone tests. --- .../classification_creation.ts | 2 +- .../data_frame_analytics/cloning.ts | 9 ++++++--- .../outlier_detection_creation.ts | 2 +- .../regression_creation.ts | 2 +- .../services/machine_learning/api.ts | 19 ++++++++++++------- .../data_frame_analytics_creation.ts | 11 +++++++---- x-pack/test/functional/services/ml.ts | 3 ++- 7 files changed, 30 insertions(+), 18 deletions(-) diff --git a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/classification_creation.ts b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/classification_creation.ts index 1bcdeef394c00..a7c92cac2072f 100644 --- a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/classification_creation.ts +++ b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/classification_creation.ts @@ -111,7 +111,7 @@ export default function({ getService }: FtrProviderContext) { it('creates the analytics job', async () => { await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); - await ml.dataFrameAnalyticsCreation.createAnalyticsJob(); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); }); it('starts the analytics job', async () => { diff --git a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts index 51155fccc358d..caf382b532273 100644 --- a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts +++ b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts @@ -13,8 +13,7 @@ export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - // failing test, see https://github.com/elastic/kibana/issues/60389 - describe.skip('jobs cloning supported by UI form', function() { + describe('jobs cloning supported by UI form', function() { this.tags(['smoke']); const testDataList: Array<{ @@ -173,7 +172,7 @@ export default function({ getService }: FtrProviderContext) { }); it('should create a clone job', async () => { - await ml.dataFrameAnalyticsCreation.createAnalyticsJob(); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(cloneJobId); }); it('should start the clone analytics job', async () => { @@ -186,6 +185,10 @@ export default function({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.closeCreateAnalyticsJobFlyout(); }); + it('finishes analytics processing', async () => { + await ml.dataFrameAnalytics.waitForAnalyticsCompletion(cloneJobId); + }); + it('displays the created job in the analytics table', async () => { await ml.dataFrameAnalyticsTable.refreshAnalyticsTable(); await ml.dataFrameAnalyticsTable.filterWithSearchString(cloneJobId); diff --git a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/outlier_detection_creation.ts index 173430706f7e7..5481977351d8b 100644 --- a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/outlier_detection_creation.ts +++ b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/outlier_detection_creation.ts @@ -107,7 +107,7 @@ export default function({ getService }: FtrProviderContext) { it('creates the analytics job', async () => { await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); - await ml.dataFrameAnalyticsCreation.createAnalyticsJob(); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); }); it('starts the analytics job', async () => { diff --git a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/regression_creation.ts index 3bc524a88f787..aa1a133c81187 100644 --- a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/regression_creation.ts @@ -111,7 +111,7 @@ export default function({ getService }: FtrProviderContext) { it('creates the analytics job', async () => { await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); - await ml.dataFrameAnalyticsCreation.createAnalyticsJob(); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); }); it('starts the analytics job', async () => { diff --git a/x-pack/test/functional/services/machine_learning/api.ts b/x-pack/test/functional/services/machine_learning/api.ts index e305d23c1a124..74dc5912df36f 100644 --- a/x-pack/test/functional/services/machine_learning/api.ts +++ b/x-pack/test/functional/services/machine_learning/api.ts @@ -358,9 +358,20 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { }, async getDataFrameAnalyticsJob(analyticsId: string) { + log.debug(`Fetching data frame analytics job '${analyticsId}'...`); return await esSupertest.get(`/_ml/data_frame/analytics/${analyticsId}`).expect(200); }, + async waitForDataFrameAnalyticsJobToExist(analyticsId: string) { + await retry.waitForWithTimeout(`'${analyticsId}' to exist`, 5 * 1000, async () => { + if (await this.getDataFrameAnalyticsJob(analyticsId)) { + return true; + } else { + throw new Error(`expected data frame analytics job '${analyticsId}' to exist`); + } + }); + }, + async createDataFrameAnalyticsJob(jobConfig: DataFrameAnalyticsConfig) { const { id: analyticsId, ...analyticsConfig } = jobConfig; log.debug(`Creating data frame analytic job with id '${analyticsId}'...`); @@ -369,13 +380,7 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { .send(analyticsConfig) .expect(200); - await retry.waitForWithTimeout(`'${analyticsId}' to be created`, 5 * 1000, async () => { - if (await this.getDataFrameAnalyticsJob(analyticsId)) { - return true; - } else { - throw new Error(`expected data frame analytics job '${analyticsId}' to be created`); - } - }); + await this.waitForDataFrameAnalyticsJobToExist(analyticsId); }, }; } diff --git a/x-pack/test/functional/services/machine_learning/data_frame_analytics_creation.ts b/x-pack/test/functional/services/machine_learning/data_frame_analytics_creation.ts index 9d5f5753e8b04..49f9b01c96895 100644 --- a/x-pack/test/functional/services/machine_learning/data_frame_analytics_creation.ts +++ b/x-pack/test/functional/services/machine_learning/data_frame_analytics_creation.ts @@ -12,6 +12,7 @@ import { import { FtrProviderContext } from '../../ftr_provider_context'; import { MlCommon } from './common'; +import { MlApi } from './api'; enum ANALYSIS_CONFIG_TYPE { OUTLIER_DETECTION = 'outlier_detection', @@ -31,7 +32,8 @@ const isClassificationAnalysis = (arg: any): arg is ClassificationAnalysis => { export function MachineLearningDataFrameAnalyticsCreationProvider( { getService }: FtrProviderContext, - mlCommon: MlCommon + mlCommon: MlCommon, + mlApi: MlApi ) { const testSubjects = getService('testSubjects'); const comboBox = getService('comboBox'); @@ -333,12 +335,13 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( return !isEnabled; }, - async createAnalyticsJob() { + async createAnalyticsJob(analyticsId: string) { await testSubjects.click('mlAnalyticsCreateJobFlyoutCreateButton'); await retry.tryForTime(5000, async () => { await this.assertCreateButtonMissing(); await this.assertStartButtonExists(); }); + await mlApi.waitForDataFrameAnalyticsJobToExist(analyticsId); }, async assertStartButtonExists() { @@ -362,8 +365,8 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( }, async closeCreateAnalyticsJobFlyout() { - await testSubjects.click('mlAnalyticsCreateJobFlyoutCloseButton'); - await retry.tryForTime(5000, async () => { + await retry.tryForTime(10 * 1000, async () => { + await testSubjects.click('mlAnalyticsCreateJobFlyoutCloseButton'); await testSubjects.missingOrFail('mlAnalyticsCreateJobFlyout'); }); }, diff --git a/x-pack/test/functional/services/ml.ts b/x-pack/test/functional/services/ml.ts index f3981c9edf92f..af7cb51f4e3f0 100644 --- a/x-pack/test/functional/services/ml.ts +++ b/x-pack/test/functional/services/ml.ts @@ -45,7 +45,8 @@ export function MachineLearningProvider(context: FtrProviderContext) { const dataFrameAnalytics = MachineLearningDataFrameAnalyticsProvider(context, api); const dataFrameAnalyticsCreation = MachineLearningDataFrameAnalyticsCreationProvider( context, - common + common, + api ); const dataFrameAnalyticsTable = MachineLearningDataFrameAnalyticsTableProvider(context); const dataVisualizer = MachineLearningDataVisualizerProvider(context); From 462be16879539559818649352531b35e9329908a Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Tue, 24 Mar 2020 01:14:41 -0600 Subject: [PATCH 058/179] [SIEM] Overview: Recent cases widget (#60993) ## [SIEM] Overview: Recent cases widget Implements the new `Recent cases` widget on the Overview page. Recent cases shows the last 3 recently created cases, per the following animated gif: ![recent-cases](https://user-images.githubusercontent.com/4459398/77357982-ae550a80-6d0e-11ea-90d0-62fa5407eea5.gif) ### Markdown case descriptions Markdown case descriptions are rendered, per the following animated gif: ![markdown-description](https://user-images.githubusercontent.com/4459398/77358163-f7a55a00-6d0e-11ea-8b85-dd4b3ff093ee.gif) ### My recently reported cases My recently reported cases filters the widget to show only cases created by the logged-in user, per the following animated gif: ![my-recent-cases](https://user-images.githubusercontent.com/4459398/77358223-14419200-6d0f-11ea-8e4a-25cd55fdfc44.gif) ### No cases state A message welcoming the user to create a case is displayed when no cases exist, per the following screenshot: ![no-cases-created](https://user-images.githubusercontent.com/4459398/77358338-4ce16b80-6d0f-11ea-98d3-5de1be19a935.png) ### Other changes - [x] Case-related links were updated to ensure URL state parameters, e.g. global date selection, carry-over as the user navigates through case views - [x] Recent timelines was updated to only show the last 3 recent timelines (down from 5) - [x] All sidebar widgets have slightly more compact spacing Tested in: * Chrome `80.0.3987.149` * Firefox `74.0` * Safari `13.0.5` --- .../components/link_to/redirect_to_case.tsx | 17 ++- .../siem/public/components/links/index.tsx | 13 +- .../public/components/markdown/index.test.tsx | 11 ++ .../siem/public/components/markdown/index.tsx | 90 ++++++------- .../navigation/breadcrumbs/index.ts | 17 ++- .../public/components/news_feed/news_feed.tsx | 8 +- .../components/news_feed/post/index.tsx | 1 + .../components/recent_cases/filters/index.tsx | 51 ++++++++ .../public/components/recent_cases/index.tsx | 80 ++++++++++++ .../recent_cases/no_cases/index.tsx | 34 +++++ .../components/recent_cases/recent_cases.tsx | 56 ++++++++ .../components/recent_cases/translations.ts | 37 ++++++ .../public/components/recent_cases/types.ts | 7 + .../recent_timelines/counts/index.tsx | 2 +- .../recent_timelines/filters/index.tsx | 6 +- .../components/recent_timelines/index.tsx | 12 +- .../recent_timelines/recent_timelines.tsx | 9 +- .../recent_timelines/translations.ts | 8 ++ .../components/url_state/index.test.tsx | 4 +- .../siem/public/components/url_state/types.ts | 4 +- .../public/containers/case/use_get_cases.tsx | 31 +++-- .../pages/case/components/all_cases/index.tsx | 7 +- .../pages/case/components/case_view/index.tsx | 6 +- .../case/components/configure_cases/index.tsx | 10 +- .../public/pages/case/configure_cases.tsx | 47 ++++--- .../siem/public/pages/case/create_case.tsx | 37 ++++-- .../plugins/siem/public/pages/case/utils.ts | 12 +- .../public/pages/home/home_navigations.tsx | 2 +- .../public/pages/overview/sidebar/index.tsx | 20 ++- .../public/pages/overview/sidebar/sidebar.tsx | 123 +++++++++++++----- .../public/pages/overview/translations.ts | 4 + 31 files changed, 594 insertions(+), 172 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/filters/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/no_cases/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/recent_cases.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/translations.ts create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/types.ts diff --git a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx index 20ba0b50f5126..6ec15b55ba83d 100644 --- a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx +++ b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; +import { appendSearch } from './helpers'; import { RedirectWrapper } from './redirect_wrapper'; import { SiemPageName } from '../../pages/home/types'; @@ -30,8 +31,14 @@ export const RedirectToConfigureCasesPage = () => ( const baseCaseUrl = `#/link-to/${SiemPageName.case}`; -export const getCaseUrl = () => baseCaseUrl; -export const getCaseDetailsUrl = (detailName: string, search: string) => - `${baseCaseUrl}/${detailName}${search}`; -export const getCreateCaseUrl = (search: string) => `${baseCaseUrl}/create${search}`; -export const getConfigureCasesUrl = (search: string) => `${baseCaseUrl}/configure${search}`; +export const getCaseUrl = (search: string | null) => + `${baseCaseUrl}${appendSearch(search ?? undefined)}`; + +export const getCaseDetailsUrl = ({ id, search }: { id: string; search: string | null }) => + `${baseCaseUrl}/${encodeURIComponent(id)}${appendSearch(search ?? undefined)}`; + +export const getCreateCaseUrl = (search: string | null) => + `${baseCaseUrl}/create${appendSearch(search ?? undefined)}`; + +export const getConfigureCasesUrl = (search: string) => + `${baseCaseUrl}/configure${appendSearch(search ?? undefined)}`; diff --git a/x-pack/legacy/plugins/siem/public/components/links/index.tsx b/x-pack/legacy/plugins/siem/public/components/links/index.tsx index 935df9ad3361f..14dc5e7999a65 100644 --- a/x-pack/legacy/plugins/siem/public/components/links/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/links/index.tsx @@ -23,11 +23,11 @@ import { import { FlowTarget, FlowTargetSourceDest } from '../../graphql/types'; import { useUiSetting$ } from '../../lib/kibana'; import { IP_REPUTATION_LINKS_SETTING } from '../../../common/constants'; -import { navTabs } from '../../pages/home/home_navigations'; import * as i18n from '../page/network/ip_overview/translations'; import { isUrlInvalid } from '../../pages/detection_engine/rules/components/step_about_rule/helpers'; -import { useGetUrlSearch } from '../navigation/use_get_url_search'; import { ExternalLinkIcon } from '../external_link_icon'; +import { navTabs } from '../../pages/home/home_navigations'; +import { useGetUrlSearch } from '../navigation/use_get_url_search'; export const DEFAULT_NUMBER_OF_LINK = 5; @@ -92,10 +92,11 @@ const CaseDetailsLinkComponent: React.FC<{ children?: React.ReactNode; detailNam children, detailName, }) => { - const urlSearch = useGetUrlSearch(navTabs.case); + const search = useGetUrlSearch(navTabs.case); + return ( {children ? children : detailName} @@ -106,8 +107,8 @@ export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent); CaseDetailsLink.displayName = 'CaseDetailsLink'; export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => { - const urlSearch = useGetUrlSearch(navTabs.case); - return {children}; + const search = useGetUrlSearch(navTabs.case); + return {children}; }); CreateCaseLink.displayName = 'CreateCaseLink'; diff --git a/x-pack/legacy/plugins/siem/public/components/markdown/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/markdown/index.test.tsx index de662c162fc0a..89af9202a597e 100644 --- a/x-pack/legacy/plugins/siem/public/components/markdown/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/markdown/index.test.tsx @@ -126,6 +126,17 @@ describe('Markdown', () => { ).toHaveProperty('href', 'https://google.com/'); }); + test('it does NOT render the href if links are disabled', () => { + const wrapper = mount(); + + expect( + wrapper + .find('[data-test-subj="markdown-link"]') + .first() + .getDOMNode() + ).not.toHaveProperty('href'); + }); + test('it opens links in a new tab via target="_blank"', () => { const wrapper = mount(); diff --git a/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx b/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx index 30695c9d0c7e2..1368c13619d6b 100644 --- a/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx @@ -26,51 +26,53 @@ const REL_NOFOLLOW = 'nofollow'; /** prevents the browser from sending the current address as referrer via the Referer HTTP header */ const REL_NOREFERRER = 'noreferrer'; -export const Markdown = React.memo<{ raw?: string; size?: 'xs' | 's' | 'm' }>( - ({ raw, size = 's' }) => { - const markdownRenderers = { - root: ({ children }: { children: React.ReactNode[] }) => ( - +export const Markdown = React.memo<{ + disableLinks?: boolean; + raw?: string; + size?: 'xs' | 's' | 'm'; +}>(({ disableLinks = false, raw, size = 's' }) => { + const markdownRenderers = { + root: ({ children }: { children: React.ReactNode[] }) => ( + + {children} + + ), + table: ({ children }: { children: React.ReactNode[] }) => ( + + {children} +
+ ), + tableHead: ({ children }: { children: React.ReactNode[] }) => ( + {children} + ), + tableRow: ({ children }: { children: React.ReactNode[] }) => ( + {children} + ), + tableCell: ({ children }: { children: React.ReactNode[] }) => ( + {children} + ), + link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => ( + + {children} -
- ), - table: ({ children }: { children: React.ReactNode[] }) => ( - - {children} -
- ), - tableHead: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - tableRow: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - tableCell: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => ( - - - {children} - - - ), - }; +
+
+ ), + }; - return ( - - ); - } -); + return ( + + ); +}); Markdown.displayName = 'Markdown'; diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts b/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts index e25fb4374bb14..155f63145ca95 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts +++ b/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts @@ -107,7 +107,22 @@ export const getBreadcrumbsForRoute = ( ]; } if (isCaseRoutes(spyState) && object.navTabs) { - return [...siemRootBreadcrumb, ...getCaseDetailsBreadcrumbs(spyState)]; + const tempNav: SearchNavTab = { urlKey: 'case', isDetailPage: false }; + let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; + if (spyState.tabName != null) { + urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; + } + + return [ + ...siemRootBreadcrumb, + ...getCaseDetailsBreadcrumbs( + spyState, + urlStateKeys.reduce( + (acc: string[], item: SearchNavTab) => [...acc, getSearch(item, object)], + [] + ) + ), + ]; } if ( spyState != null && diff --git a/x-pack/legacy/plugins/siem/public/components/news_feed/news_feed.tsx b/x-pack/legacy/plugins/siem/public/components/news_feed/news_feed.tsx index 98eea1eaa6454..cd356212b4400 100644 --- a/x-pack/legacy/plugins/siem/public/components/news_feed/news_feed.tsx +++ b/x-pack/legacy/plugins/siem/public/components/news_feed/news_feed.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiSpacer } from '@elastic/eui'; import React from 'react'; import { LoadingPlaceholders } from '../page/overview/loading_placeholders'; @@ -30,12 +29,7 @@ const NewsFeedComponent: React.FC = ({ news }) => ( ) : news.length === 0 ? ( ) : ( - news.map((n: NewsItem) => ( - - - - - )) + news.map((n: NewsItem) => ) )} ); diff --git a/x-pack/legacy/plugins/siem/public/components/news_feed/post/index.tsx b/x-pack/legacy/plugins/siem/public/components/news_feed/post/index.tsx index cb2542a497f08..9cab78c9f20b1 100644 --- a/x-pack/legacy/plugins/siem/public/components/news_feed/post/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/news_feed/post/index.tsx @@ -45,6 +45,7 @@ export const Post = React.memo<{ newsItem: NewsItem }>(({ newsItem }) => {
{description}
+
diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/filters/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_cases/filters/index.tsx new file mode 100644 index 0000000000000..edb0b99cbff8b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/filters/index.tsx @@ -0,0 +1,51 @@ +/* + * 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 { EuiButtonGroup, EuiButtonGroupOption } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; + +import { FilterMode } from '../types'; + +import * as i18n from '../translations'; + +const MY_RECENTLY_REPORTED_ID = 'myRecentlyReported'; + +const toggleButtonIcons: EuiButtonGroupOption[] = [ + { + id: 'recentlyCreated', + label: i18n.RECENTLY_CREATED_CASES, + iconType: 'folderExclamation', + }, + { + id: MY_RECENTLY_REPORTED_ID, + label: i18n.MY_RECENTLY_REPORTED_CASES, + iconType: 'reporter', + }, +]; + +export const Filters = React.memo<{ + filterBy: FilterMode; + setFilterBy: (filterBy: FilterMode) => void; + showMyRecentlyReported: boolean; +}>(({ filterBy, setFilterBy, showMyRecentlyReported }) => { + const options = useMemo( + () => + showMyRecentlyReported + ? toggleButtonIcons + : toggleButtonIcons.filter(x => x.id !== MY_RECENTLY_REPORTED_ID), + [showMyRecentlyReported] + ); + const onChange = useCallback( + (filterMode: string) => { + setFilterBy(filterMode as FilterMode); + }, + [setFilterBy] + ); + + return ; +}); + +Filters.displayName = 'Filters'; diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_cases/index.tsx new file mode 100644 index 0000000000000..07246c6c6ec88 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/index.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiHorizontalRule, EuiLink, EuiText } from '@elastic/eui'; +import React, { useEffect, useMemo, useRef } from 'react'; + +import { FilterOptions, QueryParams } from '../../containers/case/types'; +import { DEFAULT_QUERY_PARAMS, useGetCases } from '../../containers/case/use_get_cases'; +import { getCaseUrl } from '../link_to/redirect_to_case'; +import { useGetUrlSearch } from '../navigation/use_get_url_search'; +import { LoadingPlaceholders } from '../page/overview/loading_placeholders'; +import { navTabs } from '../../pages/home/home_navigations'; + +import { NoCases } from './no_cases'; +import { RecentCases } from './recent_cases'; +import * as i18n from './translations'; + +const usePrevious = (value: FilterOptions) => { + const ref = useRef(); + useEffect(() => { + (ref.current as unknown) = value; + }); + return ref.current; +}; + +const MAX_CASES_TO_SHOW = 3; + +const queryParams: QueryParams = { + ...DEFAULT_QUERY_PARAMS, + perPage: MAX_CASES_TO_SHOW, +}; + +const StatefulRecentCasesComponent = React.memo( + ({ filterOptions }: { filterOptions: FilterOptions }) => { + const previousFilterOptions = usePrevious(filterOptions); + const { data, loading, setFilters } = useGetCases(queryParams); + const isLoadingCases = useMemo( + () => loading.indexOf('cases') > -1 || loading.indexOf('caseUpdate') > -1, + [loading] + ); + const search = useGetUrlSearch(navTabs.case); + const allCasesLink = useMemo( + () => {i18n.VIEW_ALL_CASES}, + [search] + ); + + useEffect(() => { + if (previousFilterOptions !== undefined && previousFilterOptions !== filterOptions) { + setFilters(filterOptions); + } + }, [previousFilterOptions, filterOptions, setFilters]); + + const content = useMemo( + () => + isLoadingCases ? ( + + ) : !isLoadingCases && data.cases.length === 0 ? ( + + ) : ( + + ), + [isLoadingCases, data] + ); + + return ( + + {content} + + {allCasesLink} + + ); + } +); + +StatefulRecentCasesComponent.displayName = 'StatefulRecentCasesComponent'; + +export const StatefulRecentCases = React.memo(StatefulRecentCasesComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/no_cases/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_cases/no_cases/index.tsx new file mode 100644 index 0000000000000..9f0361311b7b6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/no_cases/index.tsx @@ -0,0 +1,34 @@ +/* + * 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 { EuiLink } from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { getCreateCaseUrl } from '../../link_to/redirect_to_case'; +import { useGetUrlSearch } from '../../navigation/use_get_url_search'; +import { navTabs } from '../../../pages/home/home_navigations'; + +import * as i18n from '../translations'; + +const NoCasesComponent = () => { + const urlSearch = useGetUrlSearch(navTabs.case); + const newCaseLink = useMemo( + () => {` ${i18n.START_A_NEW_CASE}`}, + [urlSearch] + ); + + return ( + <> + {i18n.NO_CASES} + {newCaseLink} + {'!'} + + ); +}; + +NoCasesComponent.displayName = 'NoCasesComponent'; + +export const NoCases = React.memo(NoCasesComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/recent_cases.tsx b/x-pack/legacy/plugins/siem/public/components/recent_cases/recent_cases.tsx new file mode 100644 index 0000000000000..eb17c75f4111b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/recent_cases.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +import { Case } from '../../containers/case/types'; +import { getCaseDetailsUrl } from '../link_to/redirect_to_case'; +import { Markdown } from '../markdown'; +import { useGetUrlSearch } from '../navigation/use_get_url_search'; +import { navTabs } from '../../pages/home/home_navigations'; +import { IconWithCount } from '../recent_timelines/counts'; + +import * as i18n from './translations'; + +const MarkdownContainer = styled.div` + max-height: 150px; + overflow-y: auto; + width: 300px; +`; + +const RecentCasesComponent = ({ cases }: { cases: Case[] }) => { + const search = useGetUrlSearch(navTabs.case); + + return ( + <> + {cases.map((c, i) => ( + + + + {c.title} + + + + {c.description && c.description.length && ( + + + + + + )} + {i !== cases.length - 1 && } + + + ))} + + ); +}; + +RecentCasesComponent.displayName = 'RecentCasesComponent'; + +export const RecentCases = React.memo(RecentCasesComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/translations.ts b/x-pack/legacy/plugins/siem/public/components/recent_cases/translations.ts new file mode 100644 index 0000000000000..d2318e5db88c3 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/translations.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const COMMENTS = i18n.translate('xpack.siem.recentCases.commentsTooltip', { + defaultMessage: 'Comments', +}); + +export const MY_RECENTLY_REPORTED_CASES = i18n.translate( + 'xpack.siem.overview.myRecentlyReportedCasesButtonLabel', + { + defaultMessage: 'My recently reported cases', + } +); + +export const NO_CASES = i18n.translate('xpack.siem.recentCases.noCasesMessage', { + defaultMessage: 'No cases have been created yet. Put your detective hat on and', +}); + +export const RECENTLY_CREATED_CASES = i18n.translate( + 'xpack.siem.overview.recentlyCreatedCasesButtonLabel', + { + defaultMessage: 'Recently created cases', + } +); + +export const START_A_NEW_CASE = i18n.translate('xpack.siem.recentCases.startNewCaseLink', { + defaultMessage: 'start a new case', +}); + +export const VIEW_ALL_CASES = i18n.translate('xpack.siem.recentCases.viewAllCasesLink', { + defaultMessage: 'View all cases', +}); diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/types.ts b/x-pack/legacy/plugins/siem/public/components/recent_cases/types.ts new file mode 100644 index 0000000000000..29c7072ce0be6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/types.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 type FilterMode = 'recentlyCreated' | 'myRecentlyReported'; diff --git a/x-pack/legacy/plugins/siem/public/components/recent_timelines/counts/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_timelines/counts/index.tsx index e04b6319cfb24..c80530b245cf3 100644 --- a/x-pack/legacy/plugins/siem/public/components/recent_timelines/counts/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/recent_timelines/counts/index.tsx @@ -21,7 +21,7 @@ const FlexGroup = styled(EuiFlexGroup)` margin-right: 16px; `; -const IconWithCount = React.memo<{ count: number; icon: string; tooltip: string }>( +export const IconWithCount = React.memo<{ count: number; icon: string; tooltip: string }>( ({ count, icon, tooltip }) => ( diff --git a/x-pack/legacy/plugins/siem/public/components/recent_timelines/filters/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_timelines/filters/index.tsx index de8a3de8094d0..d7271197b9cea 100644 --- a/x-pack/legacy/plugins/siem/public/components/recent_timelines/filters/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/recent_timelines/filters/index.tsx @@ -9,15 +9,17 @@ import React from 'react'; import { FilterMode } from '../types'; +import * as i18n from '../translations'; + const toggleButtonIcons: EuiButtonGroupOption[] = [ { id: 'favorites', - label: 'Favorites', + label: i18n.FAVORITES, iconType: 'starFilled', }, { id: `recently-updated`, - label: 'Last updated', + label: i18n.LAST_UPDATED, iconType: 'documentEdit', }, ]; diff --git a/x-pack/legacy/plugins/siem/public/components/recent_timelines/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_timelines/index.tsx index 007665b47dedb..5b851701b973c 100644 --- a/x-pack/legacy/plugins/siem/public/components/recent_timelines/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/recent_timelines/index.tsx @@ -31,6 +31,8 @@ interface OwnProps { export type Props = OwnProps & PropsFromRedux; +const PAGE_SIZE = 3; + const StatefulRecentTimelinesComponent = React.memo( ({ apolloClient, filterBy, updateIsLoading, updateTimeline }) => { const onOpenTimeline: OnOpenTimeline = useCallback( @@ -53,12 +55,18 @@ const StatefulRecentTimelinesComponent = React.memo( () => {i18n.VIEW_ALL_TIMELINES}, [urlSearch] ); + const loadingPlaceholders = useMemo( + () => ( + + ), + [filterBy] + ); return ( ( {({ timelines, loading }) => ( <> {loading ? ( - + loadingPlaceholders ) : ( {t.description && t.description.length && ( - <> - - - {t.description} - - + + {t.description} + )}
diff --git a/x-pack/legacy/plugins/siem/public/components/recent_timelines/translations.ts b/x-pack/legacy/plugins/siem/public/components/recent_timelines/translations.ts index e547272fde6e1..f5934aa317242 100644 --- a/x-pack/legacy/plugins/siem/public/components/recent_timelines/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/recent_timelines/translations.ts @@ -13,6 +13,10 @@ export const ERROR_RETRIEVING_USER_DETAILS = i18n.translate( } ); +export const FAVORITES = i18n.translate('xpack.siem.recentTimelines.favoritesButtonLabel', { + defaultMessage: 'Favorites', +}); + export const NO_FAVORITE_TIMELINES = i18n.translate( 'xpack.siem.recentTimelines.noFavoriteTimelinesMessage', { @@ -21,6 +25,10 @@ export const NO_FAVORITE_TIMELINES = i18n.translate( } ); +export const LAST_UPDATED = i18n.translate('xpack.siem.recentTimelines.lastUpdatedButtonLabel', { + defaultMessage: 'Last updated', +}); + export const NO_TIMELINES = i18n.translate('xpack.siem.recentTimelines.noTimelinesMessage', { defaultMessage: "You haven't created any timelines yet. Get out there and start threat hunting!", }); diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx index 6e957313d9b04..4d2a717153894 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx @@ -157,9 +157,7 @@ describe('UrlStateContainer', () => { ).toEqual({ hash: '', pathname: examplePath, - search: [CONSTANTS.timelinePage].includes(page) - ? `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))` - : `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))`, + search: `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))`, state: '', }); } diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts index c6f49d8a0e49b..9d8a4a8e6a908 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts @@ -64,15 +64,15 @@ export const URL_STATE_KEYS: Record = { CONSTANTS.appQuery, CONSTANTS.filters, CONSTANTS.savedQuery, - CONSTANTS.timeline, CONSTANTS.timerange, + CONSTANTS.timeline, ], case: [ CONSTANTS.appQuery, CONSTANTS.filters, CONSTANTS.savedQuery, - CONSTANTS.timeline, CONSTANTS.timerange, + CONSTANTS.timeline, ], }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx index 6c4a6ac4fe58a..ae7b8f3c043fa 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx @@ -88,6 +88,20 @@ const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesS } }; +export const DEFAULT_FILTER_OPTIONS: FilterOptions = { + search: '', + reporters: [], + status: 'open', + tags: [], +}; + +export const DEFAULT_QUERY_PARAMS: QueryParams = { + page: DEFAULT_TABLE_ACTIVE_PAGE, + perPage: DEFAULT_TABLE_LIMIT, + sortField: SortFieldCase.createdAt, + sortOrder: 'desc', +}; + const initialData: AllCases = { cases: [], countClosedCases: null, @@ -109,23 +123,14 @@ interface UseGetCases extends UseGetCasesState { setQueryParams: (queryParams: QueryParams) => void; setSelectedCases: (mySelectedCases: Case[]) => void; } -export const useGetCases = (): UseGetCases => { + +export const useGetCases = (initialQueryParams?: QueryParams): UseGetCases => { const [state, dispatch] = useReducer(dataFetchReducer, { data: initialData, - filterOptions: { - search: '', - reporters: [], - status: 'open', - tags: [], - }, + filterOptions: DEFAULT_FILTER_OPTIONS, isError: false, loading: [], - queryParams: { - page: DEFAULT_TABLE_ACTIVE_PAGE, - perPage: DEFAULT_TABLE_LIMIT, - sortField: SortFieldCase.createdAt, - sortOrder: 'desc', - }, + queryParams: initialQueryParams ?? DEFAULT_QUERY_PARAMS, selectedCases: [], }); const [, dispatchToaster] = useStateToaster(); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index 87a2ea888831a..cbb9ddae22d04 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -26,6 +26,7 @@ import { useGetCases, UpdateCase } from '../../../../containers/case/use_get_cas import { useGetCasesStatus } from '../../../../containers/case/use_get_cases_status'; import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; import { EuiBasicTableOnChange } from '../../../detection_engine/rules/types'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; import { Panel } from '../../../../components/panel'; import { UtilityBar, @@ -35,16 +36,15 @@ import { UtilityBarText, } from '../../../../components/utility_bar'; import { getConfigureCasesUrl, getCreateCaseUrl } from '../../../../components/link_to'; -import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; -import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; -import { navTabs } from '../../../home/home_navigations'; import { getBulkItems } from '../bulk_actions'; import { CaseHeaderPage } from '../case_header_page'; import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; import { OpenClosedStats } from '../open_closed_stats'; +import { navTabs } from '../../../home/home_navigations'; import { getActions } from './actions'; import { CasesTableFilters } from './table_filters'; +import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; const Div = styled.div` margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; @@ -77,6 +77,7 @@ const getSortField = (field: string): SortFieldCase => { }; export const AllCases = React.memo(() => { const urlSearch = useGetUrlSearch(navTabs.case); + const { countClosedCases, countOpenCases, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 742921cb9f69e..5c20b53f5fcb9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -25,11 +25,13 @@ import { useGetCase } from '../../../../containers/case/use_get_case'; import { UserActionTree } from '../user_action_tree'; import { UserList } from '../user_list'; import { useUpdateCase } from '../../../../containers/case/use_update_case'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; import { WrapperPage } from '../../../../components/wrapper_page'; import { getTypedPayload } from '../../../../containers/case/utils'; import { WhitePageWrapper } from '../wrappers'; import { useBasePath } from '../../../../lib/kibana'; import { CaseStatus } from '../case_status'; +import { navTabs } from '../../../home/home_navigations'; import { SpyRoute } from '../../../../utils/route/spy_routes'; import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; import { usePushToService } from './push_to_service'; @@ -61,6 +63,8 @@ export interface CaseProps { export const CaseComponent = React.memo(({ caseId, initialData }) => { const basePath = window.location.origin + useBasePath(); const caseLink = `${basePath}/app/siem#/case/${caseId}`; + const search = useGetUrlSearch(navTabs.case); + const [initLoadingData, setInitLoadingData] = useState(true); const { caseUserActions, @@ -190,7 +194,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => css` @@ -73,6 +72,7 @@ const actionTypes: ActionType[] = [ ]; const ConfigureCasesComponent: React.FC = () => { + const search = useGetUrlSearch(navTabs.case); const { http, triggers_actions_ui, notifications, application } = useKibana().services; const [connectorIsValid, setConnectorIsValid] = useState(true); @@ -235,7 +235,7 @@ const ConfigureCasesComponent: React.FC = () => { isDisabled={isLoadingAny} isLoading={persistLoading} aria-label="Cancel" - href={CASE_URL} + href={getCaseUrl(search)} > {i18n.CANCEL} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx b/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx index b546a88744439..b7e7ced308331 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { WrapperPage } from '../../components/wrapper_page'; import { CaseHeaderPage } from './components/case_header_page'; @@ -13,11 +13,8 @@ import { getCaseUrl } from '../../components/link_to'; import { WhitePageWrapper, SectionWrapper } from './components/wrappers'; import * as i18n from './translations'; import { ConfigureCases } from './components/configure_cases'; - -const backOptions = { - href: getCaseUrl(), - text: i18n.BACK_TO_ALL, -}; +import { useGetUrlSearch } from '../../components/navigation/use_get_url_search'; +import { navTabs } from '../home/home_navigations'; const wrapperPageStyle: Record = { paddingLeft: '0', @@ -25,18 +22,30 @@ const wrapperPageStyle: Record = { paddingBottom: '0', }; -const ConfigureCasesPageComponent: React.FC = () => ( - <> - - - - - - - - - - -); +const ConfigureCasesPageComponent: React.FC = () => { + const search = useGetUrlSearch(navTabs.case); + + const backOptions = useMemo( + () => ({ + href: getCaseUrl(search), + text: i18n.BACK_TO_ALL, + }), + [search] + ); + + return ( + <> + + + + + + + + + + + ); +}; export const ConfigureCasesPage = React.memo(ConfigureCasesPageComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx b/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx index 2c7525264f71b..bd1f6da0ca28b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { WrapperPage } from '../../components/wrapper_page'; import { Create } from './components/create'; @@ -12,20 +12,29 @@ import { SpyRoute } from '../../utils/route/spy_routes'; import { CaseHeaderPage } from './components/case_header_page'; import * as i18n from './translations'; import { getCaseUrl } from '../../components/link_to'; +import { useGetUrlSearch } from '../../components/navigation/use_get_url_search'; +import { navTabs } from '../home/home_navigations'; -const backOptions = { - href: getCaseUrl(), - text: i18n.BACK_TO_ALL, -}; +export const CreateCasePage = React.memo(() => { + const search = useGetUrlSearch(navTabs.case); -export const CreateCasePage = React.memo(() => ( - <> - - - - - - -)); + const backOptions = useMemo( + () => ({ + href: getCaseUrl(search), + text: i18n.BACK_TO_ALL, + }), + [search] + ); + + return ( + <> + + + + + + + ); +}); CreateCasePage.displayName = 'CreateCasePage'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts index 3f2964b8cdd6d..df9f0d08e728c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts @@ -4,16 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty } from 'lodash/fp'; import { Breadcrumb } from 'ui/chrome'; + import { getCaseDetailsUrl, getCaseUrl, getCreateCaseUrl } from '../../components/link_to'; import { RouteSpyState } from '../../utils/route/types'; import * as i18n from './translations'; -export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { +export const getBreadcrumbs = (params: RouteSpyState, search: string[]): Breadcrumb[] => { + const queryParameters = !isEmpty(search[0]) ? search[0] : null; + let breadcrumb = [ { text: i18n.PAGE_TITLE, - href: getCaseUrl(), + href: getCaseUrl(queryParameters), }, ]; if (params.detailName === 'create') { @@ -21,7 +25,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { ...breadcrumb, { text: i18n.CREATE_BC_TITLE, - href: getCreateCaseUrl(''), + href: getCreateCaseUrl(queryParameters), }, ]; } else if (params.detailName != null) { @@ -29,7 +33,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { ...breadcrumb, { text: params.state?.caseTitle ?? '', - href: getCaseDetailsUrl(params.detailName, ''), + href: getCaseDetailsUrl({ id: params.detailName, search: queryParameters }), }, ]; } diff --git a/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx b/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx index a087dca38de00..543469e2fddb7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx @@ -54,7 +54,7 @@ export const navTabs: SiemNavTab = { [SiemPageName.case]: { id: SiemPageName.case, name: i18n.CASE, - href: getCaseUrl(), + href: getCaseUrl(null), disabled: false, urlKey: 'case', }, diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/index.tsx index ad2821edde411..3797eae2bb853 100644 --- a/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/index.tsx @@ -6,13 +6,27 @@ import React, { useState } from 'react'; -import { FilterMode } from '../../../components/recent_timelines/types'; +import { FilterMode as RecentTimelinesFilterMode } from '../../../components/recent_timelines/types'; +import { FilterMode as RecentCasesFilterMode } from '../../../components/recent_cases/types'; + import { Sidebar } from './sidebar'; export const StatefulSidebar = React.memo(() => { - const [filterBy, setFilterBy] = useState('favorites'); + const [recentTimelinesFilterBy, setRecentTimelinesFilterBy] = useState( + 'favorites' + ); + const [recentCasesFilterBy, setRecentCasesFilterBy] = useState( + 'recentlyCreated' + ); - return ; + return ( + + ); }); StatefulSidebar.displayName = 'StatefulSidebar'; diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/sidebar.tsx b/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/sidebar.tsx index d3b85afe62a2a..52e36b472a0ec 100644 --- a/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/sidebar.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/sidebar.tsx @@ -8,12 +8,17 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React, { useMemo } from 'react'; import styled from 'styled-components'; -import { Filters } from '../../../components/recent_timelines/filters'; +import { Filters as RecentCasesFilters } from '../../../components/recent_cases/filters'; +import { Filters as RecentTimelinesFilters } from '../../../components/recent_timelines/filters'; import { ENABLE_NEWS_FEED_SETTING, NEWS_FEED_URL_SETTING } from '../../../../common/constants'; +import { StatefulRecentCases } from '../../../components/recent_cases'; import { StatefulRecentTimelines } from '../../../components/recent_timelines'; import { StatefulNewsFeed } from '../../../components/news_feed'; -import { FilterMode } from '../../../components/recent_timelines/types'; +import { FilterMode as RecentTimelinesFilterMode } from '../../../components/recent_timelines/types'; +import { FilterMode as RecentCasesFilterMode } from '../../../components/recent_cases/types'; +import { DEFAULT_FILTER_OPTIONS } from '../../../containers/case/use_get_cases'; import { SidebarHeader } from '../../../components/sidebar_header'; +import { useCurrentUser } from '../../../lib/kibana'; import { useApolloClient } from '../../../utils/apollo_context'; import * as i18n from '../translations'; @@ -22,35 +27,93 @@ const SidebarFlexGroup = styled(EuiFlexGroup)` width: 305px; `; +const SidebarSpacerComponent = () => ( + + + +); + +SidebarSpacerComponent.displayName = 'SidebarSpacerComponent'; +const Spacer = React.memo(SidebarSpacerComponent); + export const Sidebar = React.memo<{ - filterBy: FilterMode; - setFilterBy: (filterBy: FilterMode) => void; -}>(({ filterBy, setFilterBy }) => { - const apolloClient = useApolloClient(); - const RecentTimelinesFilters = useMemo( - () => , - [filterBy, setFilterBy] - ); - - return ( - - - {RecentTimelinesFilters} - - - - - - - - - void; + setRecentTimelinesFilterBy: (filterBy: RecentTimelinesFilterMode) => void; +}>( + ({ + recentCasesFilterBy, + recentTimelinesFilterBy, + setRecentCasesFilterBy, + setRecentTimelinesFilterBy, + }) => { + const currentUser = useCurrentUser(); + const apolloClient = useApolloClient(); + const recentCasesFilters = useMemo( + () => ( + - - - ); -}); + ), + [currentUser, recentCasesFilterBy, setRecentCasesFilterBy] + ); + const recentCasesFilterOptions = useMemo( + () => + recentCasesFilterBy === 'myRecentlyReported' && currentUser != null + ? { + ...DEFAULT_FILTER_OPTIONS, + reporters: [ + { + email: currentUser.email, + full_name: currentUser.fullName, + username: currentUser.username, + }, + ], + } + : DEFAULT_FILTER_OPTIONS, + [currentUser, recentCasesFilterBy] + ); + const recentTimelinesFilters = useMemo( + () => ( + + ), + [recentTimelinesFilterBy, setRecentTimelinesFilterBy] + ); + + return ( + + + {recentCasesFilters} + + + + + + + {recentTimelinesFilters} + + + + + + + + + + ); + } +); Sidebar.displayName = 'Sidebar'; diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts b/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts index 5ccd25984bc40..601a629d86e57 100644 --- a/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts @@ -26,6 +26,10 @@ export const PAGE_SUBTITLE = i18n.translate('xpack.siem.overview.pageSubtitle', defaultMessage: 'Security Information & Event Management with the Elastic Stack', }); +export const RECENT_CASES = i18n.translate('xpack.siem.overview.recentCasesSidebarTitle', { + defaultMessage: 'Recent cases', +}); + export const RECENT_TIMELINES = i18n.translate('xpack.siem.overview.recentTimelinesSidebarTitle', { defaultMessage: 'Recent timelines', }); From e96ed69bf69a48cd6575d9e10925bf4e1e8685f0 Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Tue, 24 Mar 2020 08:26:09 +0100 Subject: [PATCH 059/179] Upgrade mocha dev-dependency from 6.2.2 to 7.1.1 (#60779) Co-authored-by: spalger --- package.json | 4 +- x-pack/package.json | 4 +- yarn.lock | 237 ++++++++++++++++++++------------------------ 3 files changed, 111 insertions(+), 134 deletions(-) diff --git a/package.json b/package.json index 3421bf938cd80..08668730f9a9d 100644 --- a/package.json +++ b/package.json @@ -348,7 +348,7 @@ "@types/lru-cache": "^5.1.0", "@types/markdown-it": "^0.0.7", "@types/minimatch": "^2.0.29", - "@types/mocha": "^5.2.7", + "@types/mocha": "^7.0.2", "@types/moment-timezone": "^0.5.12", "@types/mustache": "^0.8.31", "@types/node": ">=10.17.17 <10.20.0", @@ -456,7 +456,7 @@ "license-checker": "^16.0.0", "listr": "^0.14.1", "load-grunt-config": "^3.0.1", - "mocha": "^6.2.2", + "mocha": "^7.1.1", "mock-http-server": "1.3.0", "multistream": "^2.1.1", "murmurhash3js": "3.0.1", diff --git a/x-pack/package.json b/x-pack/package.json index 116bbb92007e7..41674cac01725 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -78,7 +78,7 @@ "@types/mapbox-gl": "^0.54.1", "@types/memoize-one": "^4.1.0", "@types/mime": "^2.0.1", - "@types/mocha": "^5.2.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", @@ -145,7 +145,7 @@ "loader-utils": "^1.2.3", "madge": "3.4.4", "marge": "^1.0.1", - "mocha": "^6.2.2", + "mocha": "^7.1.1", "mocha-junit-reporter": "^1.23.1", "mochawesome": "^4.1.0", "mochawesome-merge": "^2.0.1", diff --git a/yarn.lock b/yarn.lock index bb5032f51c6c7..22b087d5a8338 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4841,10 +4841,10 @@ dependencies: "@types/node" "*" -"@types/mocha@^5.2.7": - version "5.2.7" - resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.2.7.tgz#315d570ccb56c53452ff8638738df60726d5b6ea" - integrity sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ== +"@types/mocha@^7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-7.0.2.tgz#b17f16cf933597e10d6d78eae3251e692ce8b0ce" + integrity sha512-ZvO2tAcjmMi8V/5Z3JsyofMe3hasRcaw88cto5etSVMwVQfeivGAlEYmaQgceUSVYFofVjT+ioHsATjdWcFt1w== "@types/moment-timezone@^0.5.12": version "0.5.12" @@ -7934,6 +7934,13 @@ binaryextensions@2: resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.1.1.tgz#3209a51ca4a4ad541a3b8d3d6a6d5b83a2485935" integrity sha512-XBaoWE9RW8pPdPQNibZsW2zh8TW6gcarXp1FZPwT8Uop8ScSNldJEWf2k9l3HeTqdrEwsOsFcq74RiJECW34yA== +bindings@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + bit-twiddle@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/bit-twiddle/-/bit-twiddle-1.0.2.tgz#0c6c1fabe2b23d17173d9a61b7b7093eb9e1769e" @@ -9084,6 +9091,21 @@ chokidar@3.2.1: optionalDependencies: fsevents "~2.1.0" +chokidar@3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.0.tgz#12c0714668c55800f659e262d4962a97faf554a6" + integrity sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.2.0" + optionalDependencies: + fsevents "~2.1.1" + chokidar@^2.0.0, chokidar@^2.1.2, chokidar@^2.1.8: version "2.1.8" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" @@ -11084,7 +11106,7 @@ debug-fabulous@1.X: memoizee "0.4.X" object-assign "4.X" -debug@2.6.9, debug@^2.0.0, debug@^2.1.0, debug@^2.1.1, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.8, debug@^2.6.9: +debug@2.6.9, debug@^2.0.0, debug@^2.1.0, debug@^2.1.1, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.8, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== @@ -11478,11 +11500,6 @@ detect-indent@^4.0.0: dependencies: repeating "^2.0.0" -detect-libc@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= - detect-newline@2.X, detect-newline@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" @@ -13852,6 +13869,11 @@ file-type@^9.0.0: resolved "https://registry.yarnpkg.com/file-type/-/file-type-9.0.0.tgz#a68d5ad07f486414dfb2c8866f73161946714a18" integrity sha512-Qe/5NJrgIOlwijpq3B7BEpzPFcgzggOTagZmkXQY4LA6bsXKTUstK7Wp12lEJ/mLKTpvIZxmIuRcLYWT6ov9lw== +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + filename-reserved-regex@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz#abf73dfab735d045440abfea2d91f389ebbfa229" @@ -14473,17 +14495,17 @@ fs.realpath@^1.0.0: integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= fsevents@^1.2.7: - version "1.2.7" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.7.tgz#4851b664a3783e52003b3c66eb0eee1074933aa4" - integrity sha512-Pxm6sI2MeBD7RdD12RYsqaP0nMiwx8eZBXCa6z2L+mRHm2DYrOYwihmhjpkdjUHwQhslWQjRpEgNq4XvBmaAuw== + version "1.2.12" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.12.tgz#db7e0d8ec3b0b45724fd4d83d43554a8f1f0de5c" + integrity sha512-Ggd/Ktt7E7I8pxZRbGIs7vwqAPscSESMrCSkx2FtWeqmheJgCo2R74fTsZFCifr0VTPwqRpPv17+6b8Zp7th0Q== dependencies: - nan "^2.9.2" - node-pre-gyp "^0.10.0" + bindings "^1.5.0" + nan "^2.12.1" -fsevents@~2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.0.tgz#ce1a5f9ac71c6d75278b0c5bd236d7dfece4cbaa" - integrity sha512-+iXhW3LuDQsno8dOIrCIT/CBjeBWuP7PXe8w9shnj9Lebny/Gx1ZjVBYwexLz36Ri2jKuXMNpV6CYNh8lHHgrQ== +fsevents@~2.1.0, fsevents@~2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805" + integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA== fstream@^1.0.0, fstream@^1.0.12: version "1.0.12" @@ -16502,7 +16524,7 @@ icalendar@0.7.1: resolved "https://registry.yarnpkg.com/icalendar/-/icalendar-0.7.1.tgz#d0d3486795f8f1c5cf4f8cafac081b4b4e7a32ae" integrity sha1-0NNIZ5X48cXPT4yvrAgbS056Mq4= -iconv-lite@0.4, iconv-lite@0.4.24, iconv-lite@^0.4.17, iconv-lite@^0.4.22, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: +iconv-lite@0.4, iconv-lite@0.4.24, iconv-lite@^0.4.17, iconv-lite@^0.4.22, iconv-lite@^0.4.24, iconv-lite@~0.4.13: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -16576,13 +16598,6 @@ iferr@^0.1.5: resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= -ignore-walk@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8" - integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ== - dependencies: - minimatch "^3.0.4" - ignore@^3.1.2, ignore@^3.3.5: version "3.3.10" resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043" @@ -20712,6 +20727,11 @@ minimist@^0.1.0: resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.1.0.tgz#99df657a52574c21c9057497df742790b2b4c0de" integrity sha1-md9lelJXTCHJBXSX33QnkLK0wN4= +minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + minimist@~0.0.1: version "0.0.10" resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" @@ -20754,7 +20774,7 @@ minipass@^2.2.1: safe-buffer "^5.1.1" yallist "^3.0.0" -minipass@^2.2.4, minipass@^2.3.4: +minipass@^2.2.4: version "2.3.5" resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.5.tgz#cacebe492022497f656b0f0f51e2682a9ed2d848" integrity sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA== @@ -20784,13 +20804,6 @@ minizlib@^1.1.0, minizlib@^1.2.1: dependencies: minipass "^2.2.1" -minizlib@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.1.1.tgz#6734acc045a46e61d596a43bb9d9cd326e19cc42" - integrity sha512-TrfjCjk4jLhcJyGMYymBH6oTXcWjYbUAXTHDbtnWHjZC25h0cdajHuPE1zxb4DVmu8crfh+HwH/WMuyLG0nHBg== - dependencies: - minipass "^2.2.1" - mississippi@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-3.0.0.tgz#ea0a3291f97e0b5e8776b363d5f0a12d94c67022" @@ -20842,6 +20855,13 @@ mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdi dependencies: minimist "0.0.8" +mkdirp@0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.3.tgz#5a514b7179259287952881e94410ec5465659f8c" + integrity sha512-P+2gwrFqx8lhew375MQHHeTlY8AuOJSrGf0R5ddkEndUkmwpgUob/vQuBD1V22/Cw1/lJr4x+EjllSezBThzBg== + dependencies: + minimist "^1.2.5" + mkdirp@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.3.tgz#4cf2e30ad45959dddea53ad97d518b6c8205e1ea" @@ -20858,13 +20878,14 @@ mocha-junit-reporter@^1.23.1: strip-ansi "^4.0.0" xml "^1.0.0" -mocha@^6.2.2: - version "6.2.2" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-6.2.2.tgz#5d8987e28940caf8957a7d7664b910dc5b2fea20" - integrity sha512-FgDS9Re79yU1xz5d+C4rv1G7QagNGHZ+iXF81hO8zY35YZZcLEsJVfFolfsqKFWunATEvNzMK0r/CwWd/szO9A== +mocha@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-7.1.1.tgz#89fbb30d09429845b1bb893a830bf5771049a441" + integrity sha512-3qQsu3ijNS3GkWcccT5Zw0hf/rWvu1fTN9sPvEd81hlwsr30GX2GcDSSoBxo24IR8FelmrAydGC6/1J5QQP4WA== dependencies: ansi-colors "3.2.3" browser-stdout "1.3.1" + chokidar "3.3.0" debug "3.2.6" diff "3.5.0" escape-string-regexp "1.0.5" @@ -20873,18 +20894,18 @@ mocha@^6.2.2: growl "1.10.5" he "1.2.0" js-yaml "3.13.1" - log-symbols "2.2.0" + log-symbols "3.0.0" minimatch "3.0.4" - mkdirp "0.5.1" + mkdirp "0.5.3" ms "2.1.1" - node-environment-flags "1.0.5" + node-environment-flags "1.0.6" object.assign "4.1.0" strip-json-comments "2.0.1" supports-color "6.0.0" which "1.3.1" wide-align "1.1.3" - yargs "13.3.0" - yargs-parser "13.1.1" + yargs "13.3.2" + yargs-parser "13.1.2" yargs-unparser "1.6.0" mochawesome-merge@^2.0.1: @@ -21140,7 +21161,7 @@ mute-stream@0.0.8: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== -nan@^2.13.2, nan@^2.9.2: +nan@^2.12.1, nan@^2.13.2: version "2.14.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== @@ -21222,15 +21243,6 @@ nearley@^2.7.10: randexp "0.4.6" semver "^5.4.1" -needle@^2.2.1: - version "2.2.4" - resolved "https://registry.yarnpkg.com/needle/-/needle-2.2.4.tgz#51931bff82533b1928b7d1d69e01f1b00ffd2a4e" - integrity sha512-HyoqEb4wr/rsoaIDfTH2aVL9nWtQqba2/HvMv+++m8u0dz808MaagKILxtfeSN7QU7nvbQ79zk3vYOJp9zsNEA== - dependencies: - debug "^2.1.2" - iconv-lite "^0.4.4" - sax "^1.2.4" - negotiator@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" @@ -21351,10 +21363,10 @@ node-dir@^0.1.10: dependencies: minimatch "^3.0.2" -node-environment-flags@1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.5.tgz#fa930275f5bf5dae188d6192b24b4c8bbac3d76a" - integrity sha512-VNYPRfGfmZLx0Ye20jWzHUjyTW/c+6Wq+iLhDzUI4XmhrDd9l/FozXV3F2xOaXjvp0co0+v1YSR3CMP6g+VvLQ== +node-environment-flags@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.6.tgz#a30ac13621f6f7d674260a54dede048c3982c088" + integrity sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw== dependencies: object.getownpropertydescriptors "^2.0.3" semver "^5.7.0" @@ -21515,22 +21527,6 @@ node-notifier@^5.4.2: shellwords "^0.1.1" which "^1.3.0" -node-pre-gyp@^0.10.0: - version "0.10.3" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc" - integrity sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4" - node-releases@^1.1.25, node-releases@^1.1.46: version "1.1.47" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.47.tgz#c59ef739a1fd7ecbd9f0b7cf5b7871e8a8b591e4" @@ -21600,14 +21596,6 @@ nopt@^2.2.0: dependencies: abbrev "1" -nopt@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" - integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00= - dependencies: - abbrev "1" - osenv "^0.1.4" - normalize-package-data@^2.0.0, normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: version "2.4.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f" @@ -21676,11 +21664,6 @@ now-and-later@^2.0.0: dependencies: once "^1.3.2" -npm-bundled@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.3.tgz#7e71703d973af3370a9591bafe3a63aca0be2308" - integrity sha512-ByQ3oJ/5ETLyglU2+8dBObvhfWXX8dtPZDMePCahptliFX2iIuhyEszyFk401PZUNQH20vvdW5MLjJxkwU80Ow== - npm-conf@^1.1.0, npm-conf@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/npm-conf/-/npm-conf-1.1.3.tgz#256cc47bd0e218c259c4e9550bf413bc2192aff9" @@ -21697,14 +21680,6 @@ npm-keyword@^5.0.0: got "^7.1.0" registry-url "^3.0.3" -npm-packlist@^1.1.6: - version "1.1.10" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.10.tgz#1039db9e985727e464df066f4cf0ab6ef85c398a" - integrity sha512-AQC0Dyhzn4EiYEfIUjCdMl0JJ61I2ER9ukf/sLxJUcZHfo+VyEfz2rMJgLZSS1v30OxPQe1cN0LZA1xbcaVfWA== - dependencies: - ignore-walk "^3.0.1" - npm-bundled "^1.0.1" - npm-run-path@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-1.0.0.tgz#f5c32bf595fe81ae927daec52e82f8b000ac3c8f" @@ -21742,7 +21717,7 @@ npmconf@^2.1.3: semver "2 || 3 || 4" uid-number "0.0.5" -"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0, npmlog@^4.0.2, npmlog@^4.1.2: +"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0, npmlog@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== @@ -22324,14 +22299,6 @@ osenv@0, osenv@^0.1.0: os-homedir "^1.0.0" os-tmpdir "^1.0.0" -osenv@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.4.tgz#42fe6d5953df06c8064be6f176c3d05aaaa34644" - integrity sha1-Qv5tWVPfBsgGS+bxdsPQWqqjRkQ= - dependencies: - os-homedir "^1.0.0" - os-tmpdir "^1.0.0" - output-file-sync@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/output-file-sync/-/output-file-sync-2.0.1.tgz#f53118282f5f553c2799541792b723a4c71430c0" @@ -25191,6 +25158,13 @@ readdirp@~3.1.3: dependencies: picomatch "^2.0.4" +readdirp@~3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.2.0.tgz#c30c33352b12c96dfb4b895421a49fd5a9593839" + integrity sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ== + dependencies: + picomatch "^2.0.4" + readline2@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/readline2/-/readline2-1.0.1.tgz#41059608ffc154757b715d9989d199ffbf372e35" @@ -26564,7 +26538,7 @@ sass-resources-loader@^2.0.1: glob "^7.1.1" loader-utils "^1.0.4" -sax@>=0.6.0, sax@^1.2.1, sax@^1.2.4, sax@~1.2.4: +sax@>=0.6.0, sax@^1.2.1, sax@~1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== @@ -28627,19 +28601,6 @@ tar@^2.0.0: fstream "^1.0.12" inherits "2" -tar@^4: - version "4.4.8" - resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.8.tgz#b19eec3fde2a96e64666df9fdb40c5ca1bc3747d" - integrity sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ== - dependencies: - chownr "^1.1.1" - fs-minipass "^1.2.5" - minipass "^2.3.4" - minizlib "^1.1.1" - mkdirp "^0.5.0" - safe-buffer "^5.1.2" - yallist "^3.0.2" - tcomb-validation@^3.3.0: version "3.4.1" resolved "https://registry.yarnpkg.com/tcomb-validation/-/tcomb-validation-3.4.1.tgz#a7696ec176ce56a081d9e019f8b732a5a8894b65" @@ -32114,10 +32075,10 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yargs-parser@13.1.1, yargs-parser@^13.0.0, yargs-parser@^13.1.0, yargs-parser@^13.1.1: - version "13.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.1.tgz#d26058532aa06d365fe091f6a1fc06b2f7e5eca0" - integrity sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ== +yargs-parser@13.1.2, yargs-parser@^13.0.0, yargs-parser@^13.1.0, yargs-parser@^13.1.1, yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== dependencies: camelcase "^5.0.0" decamelize "^1.2.0" @@ -32138,9 +32099,9 @@ yargs-parser@^11.1.1: decamelize "^1.2.0" yargs-parser@^15.0.0: - version "15.0.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-15.0.0.tgz#cdd7a97490ec836195f59f3f4dbe5ea9e8f75f08" - integrity sha512-xLTUnCMc4JhxrPEPUYD5IBR1mWCK/aT6+RJ/K29JY2y1vD+FhtgKK0AXRWvI262q3QSffAQuTouFIKUuHX89wQ== + version "15.0.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-15.0.1.tgz#54786af40b820dcb2fb8025b11b4d659d76323b3" + integrity sha512-0OAMV2mAZQrs3FkNpDQcBk1x5HXb8X4twADss4S0Iuk+2dGnLOE/fRHrsYm542GduMveyA77OF4wrNJuanRCWw== dependencies: camelcase "^5.0.0" decamelize "^1.2.0" @@ -32211,10 +32172,10 @@ yargs@13.2.4: y18n "^4.0.0" yargs-parser "^13.1.0" -yargs@13.3.0, yargs@^13.2.2, yargs@^13.3.0: - version "13.3.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.0.tgz#4c657a55e07e5f2cf947f8a366567c04a0dedc83" - integrity sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA== +yargs@13.3.2: + version "13.3.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== dependencies: cliui "^5.0.0" find-up "^3.0.0" @@ -32225,7 +32186,7 @@ yargs@13.3.0, yargs@^13.2.2, yargs@^13.3.0: string-width "^3.0.0" which-module "^2.0.0" y18n "^4.0.0" - yargs-parser "^13.1.1" + yargs-parser "^13.1.2" yargs@4.8.1: version "4.8.1" @@ -32265,6 +32226,22 @@ yargs@^11.0.0: y18n "^3.2.1" yargs-parser "^9.0.2" +yargs@^13.2.2, yargs@^13.3.0: + version "13.3.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.0.tgz#4c657a55e07e5f2cf947f8a366567c04a0dedc83" + integrity sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA== + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.1" + yargs@^14.2.0: version "14.2.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-14.2.0.tgz#f116a9242c4ed8668790b40759b4906c276e76c3" From 5abb2c8c7dbc6dfca05059261708f800ca1807bb Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Tue, 24 Mar 2020 08:31:29 +0100 Subject: [PATCH 060/179] Drilldowns (#59632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add drilldown wizard components * Dynamic actions (#58216) * feat: 🎸 add DynamicAction and FactoryAction types * feat: 🎸 add Mutable type to @kbn/utility-types * feat: 🎸 add ActionInternal and ActionContract * chore: 🤖 remove unused file * feat: 🎸 improve action interfaces * docs: ✏️ add JSDocs * feat: 🎸 simplify ui_actions interfaces * fix: 🐛 fix TypeScript types * feat: 🎸 add AbstractPresentable interface * feat: 🎸 add AbstractConfigurable interface * feat: 🎸 use AbstractPresentable in ActionInternal * test: 💍 fix ui_actions Jest tests * feat: 🎸 add state container to action * perf: ⚡️ convert MenuItem to React component on Action instance * refactor: 💡 rename AbsractPresentable -> Presentable * refactor: 💡 rename AbstractConfigurable -> Configurable * feat: 🎸 add Storybook to ui_actions * feat: 🎸 add component * feat: 🎸 improve component * chore: 🤖 use .story file extension prefix for Storybook * feat: 🎸 improve component * feat: 🎸 show error if dynamic action has CollectConfig missing * feat: 🎸 render sample action configuration component * feat: 🎸 connect action config to * feat: 🎸 improve stories * test: 💍 add ActionInternal serialize/deserialize tests * feat: 🎸 add ActionContract * feat: 🎸 split action Context into Execution and Presentation * fix: 🐛 fix TypeScript error * refactor: 💡 extract state container hooks to module scope * docs: ✏️ fix typos * chore: 🤖 remove Mutable type * test: 💍 don't cast to any getActions() function * style: 💄 avoid using unnecessary types * chore: 🤖 address PR review comments * chore: 🤖 rename ActionContext generic * chore: 🤖 remove order from state container * chore: 🤖 remove deprecation notice on getHref * test: 💍 fix tests after order field change * remove comments Co-authored-by: Matt Kime Co-authored-by: Elastic Machine * Drilldown context menu (#59638) * fix: 🐛 fix TypeScript error * feat: 🎸 add CONTEXT_MENU_DRILLDOWNS_TRIGGER trigger * fix: 🐛 correctly order context menu items * fix: 🐛 set correct order on drilldown flyout actions * fix: 🐛 clean up context menu building functions * feat: 🎸 add context menu separator action * Add basic ActionFactoryService. Pass data from it into components instead of mocks * Dashboard x pack (#59653) * feat: 🎸 add dashboard_enhanced plugin to x-pack * feat: 🎸 improve context menu separator * feat: 🎸 move drilldown flyout actions to dashboard_enhanced * fix: 🐛 fix exports from ui_actions plugin * feat: 🎸 "implement" registerDrilldown() method * fix ConfigurableBaseConfig type * Implement connected flyout_manage_drilldowns component * Simplify connected flyout manage drilldowns component. Remove intermediate component * clean up data-testid workaround in new components * Connect welcome message to storage Not sure, but use LocalStorage. Didn’t find a way to persist user settings. looks like uiSettings are not user scoped. * require `context` in Presentable. drill context down through wizard components * Drilldown factory (#59823) * refactor: 💡 import storage interface from ui_actions plugin * refactor: 💡 make actions not-dynamic * feat: 🎸 fix TypeScript errors, reshuffle types and code * fix: 🐛 fix more TypeScript errors * fix: 🐛 fix TypeScript import error * Drilldown registration (#59834) * feat: 🎸 improve drilldown registration method * fix: 🐛 set up translations for dashboard_enhanced plugin * Drilldown events 3 (#59854) * feat: 🎸 add serialize/unserialize to action * feat: 🎸 pass in uiActions service into Embeddable * feat: 🎸 merge ui_actions oss and basic plugins * refactor: 💡 move action factory registry to OSS * fix: 🐛 fix TypeScript errors * Drilldown events 4 (#59876) * feat: 🎸 mock sample drilldown execute methods * feat: 🎸 add .dynamicActions manager to Embeddable * feat: 🎸 add first version of dynamic action manager * Drilldown events 5 (#59885) * feat: 🎸 display drilldowns in context menu only on one embed * feat: 🎸 clear dynamic actions from registry when embed unloads * fix: 🐛 fix OSS TypeScript errors * basic integration of components with dynamicActionManager * fix: 🐛 don't overwrite explicitInput with combined input (#59938) * display drilldown count in embeddable edit mode * display drilldown count in embeddable edit mode * improve wizard components. more tests. * partial progress, dashboard drilldowns (#59977) * partial progress, dashboard drilldowns * partial progress, dashboard drilldowns * feat: 🎸 improve dashboard drilldown setup * feat: 🎸 wire in services into dashboard drilldown * chore: 🤖 add Storybook to dashboard_enhanced * feat: 🎸 create presentational * test: 💍 add stories * test: 💍 use presentation dashboar config component * feat: 🎸 wire in services into React component * docs: ✏️ add README to /components folder * feat: 🎸 increase importance of Dashboard drilldown * feat: 🎸 improve icon definition in drilldowns * chore: 🤖 remove unnecessary comment * chore: 🤖 add todos Co-authored-by: streamich * Manage drilldowns toasts. Add basic error handling. * support order in action factory selector * fix column order in manage drilldowns list * remove accidental debug info * bunch of nit ui fixes * Drilldowns reactive action manager (#60099) * feat: 🎸 improve isConfigValid return type * feat: 🎸 make DynamicActionManager reactive * docs: ✏️ add JSDocs to public mehtods of DynamicActionManager * feat: 🎸 make panel top-right corner number badge reactive * fix: 🐛 correctly await for .deleteEvents() * Drilldowns various 2 (#60103) * chore: 🤖 address review comments * test: 💍 fix embeddable_panel.test.tsx tests * chore: 🤖 clean up ActionInternal * chore: 🤖 make isConfigValid a simple predicate * chore: 🤖 fix TypeScript type errors * test: 💍 stub DynamicActionManager tests (#60104) * Drilldowns review 1 (#60139) * refactor: 💡 improve generic types * fix: 🐛 don't overwrite icon * fix: 🐛 fix x-pack TypeScript errors * fix: 🐛 fix TypeScript error * fix: 🐛 correct merge * Drilldowns various 4 (#60264) * feat: 🎸 hide "Create drilldown" from context menu when needed * style: 💄 remove AnyDrilldown type * feat: 🎸 add drilldown factory context * chore: 🤖 remove sample drilldown * fix: 🐛 increase spacing between action factory picker * workaround issue with closing flyout when navigating away Adds overlay just like other flyouts which makes this defect harder to bump in * fix react key issue in action_wizard * don’t open 2 flyouts * fix action order https://github.com/elastic/kibana/issues/60138 * Drilldowns reload stored (#60336) * style: 💄 don't use double equals __ * feat: 🎸 add reload$ to ActionStorage interface * feat: 🎸 add reload$ to embeddable event storage * feat: 🎸 add storage syncing to DynamicActionManager * refactor: 💡 use state from DynamicActionManager in React * fix: 🐛 add check for manager being stopped * Drilldowns triggers (#60339) * feat: 🎸 make use of supportedTriggers() * feat: 🎸 pass in context to configuration component * feat: 🎸 augment factory context * fix: 🐛 stop infinite re-rendering * Drilldowns multitrigger (#60357) * feat: 🎸 add support for multiple triggers * feat: 🎸 enable Drilldowns for TSVB Although TSVB brushing event is now broken on master, KibanaApp plans to fix it in 7.7 * "Create drilldown" flyout - design cleanup (#60309) * create drilldown flyout cleanup * remove border from selectedActionFactoryContainer * adjust callout in DrilldownHello * update form labels * remove unused file * fix type error Co-authored-by: Anton Dosov * basic unit tests for flyout_create_drildown action * Drilldowns finalize (#60371) * fix: 🐛 align flyout content to left side * fix: 🐛 move context menu item number 1px lower * fix: 🐛 move flyout back nav chevron up * fix: 🐛 fix type check after refactor * basic unit tests for drilldown actions * Drilldowns finalize 2 (#60510) * test: 💍 fix test mock * chore: 🤖 remove unused UiActionsService methods * refactor: 💡 cleanup UiActionsService action registration * fix: 🐛 add missing functionality after refactor * test: 💍 add action factory tests * test: 💍 add DynamicActionManager tests * feat: 🎸 capture error if it happens during initial load * fix: 🐛 register correctly CSV action * feat: 🎸 don't show "OPTIONS" title on drilldown context menus * feat: 🎸 add server-side for x-pack dashboard plugin * feat: 🎸 disable Drilldowns for TSVB * feat: 🎸 enable drilldowns on kibana.yml feature flag * feat: 🎸 add feature flag comment to kibana.yml * feat: 🎸 remove places from drilldown interface * refactor: 💡 remove place in factory context * chore: 🤖 remove doExecute * remove not needed now error_configure_action component * remove workaround for storybook * feat: 🎸 improve DrilldownDefinition interface * style: 💄 replace any by unknown * chore: 🤖 remove any * chore: 🤖 make isConfigValid return type a boolean * refactor: 💡 move getDisplayName to factory, remove deprecated * style: 💄 remove any * feat: 🎸 improve ActionFactoryDefinition * refactor: 💡 change visualize_embeddable params * feat: 🎸 add dashboard dependency to dashboard_enhanced * style: 💄 rename drilldown plugin life-cycle contracts * refactor: 💡 do naming adjustments for dashboard drilldown * fix: 🐛 fix Type error * fix: 🐛 fix TypeScript type errors * test: 💍 fix test after refactor * refactor: 💡 rename context -> placeContext in React component * chore: 🤖 remove setting from kibana.yml * refactor: 💡 change return type of getAction as per review * remove custom css per review * refactor: 💡 rename drilldownCount to eventCount * style: 💄 remove any * refactor: 💡 change how uiActions are passed to vis embeddable * style: 💄 remove unused import Co-authored-by: Anton Dosov Co-authored-by: Matt Kime Co-authored-by: Elastic Machine Co-authored-by: Andrea Del Rio --- .github/CODEOWNERS | 1 + examples/ui_action_examples/public/plugin.ts | 2 +- examples/ui_actions_explorer/public/app.tsx | 3 +- .../ui_actions_explorer/public/plugin.tsx | 16 +- .../public/overlays/flyout/flyout_service.tsx | 1 + src/dev/storybook/aliases.ts | 4 +- .../public/embeddable/visualize_embeddable.ts | 8 +- .../visualize_embeddable_factory.tsx | 10 +- .../public/np_ready/public/mocks.ts | 5 +- .../public/np_ready/public/plugin.ts | 6 +- .../public/actions/replace_panel_action.tsx | 2 +- src/plugins/dashboard/public/plugin.tsx | 4 +- .../public/tests/dashboard_container.test.tsx | 2 +- src/plugins/data/public/plugin.ts | 9 +- .../public/lib/actions/edit_panel_action.ts | 2 +- .../public/lib/embeddables/embeddable.tsx | 73 +- .../embeddable_action_storage.test.ts | 128 ++-- .../embeddables/embeddable_action_storage.ts | 53 +- .../public/lib/embeddables/i_embeddable.ts | 16 +- .../lib/panel/embeddable_panel.test.tsx | 14 +- .../public/lib/panel/embeddable_panel.tsx | 38 +- .../customize_title/customize_panel_action.ts | 8 +- .../panel_actions/inspect_panel_action.ts | 2 +- .../panel_actions/remove_panel_action.ts | 2 +- .../lib/panel/panel_header/panel_header.tsx | 9 +- .../create_state_container_react_helpers.ts | 69 +- .../common/state_containers/types.ts | 2 +- src/plugins/kibana_utils/index.ts | 20 + .../ui_actions/public/actions/action.ts | 26 +- .../public/actions/action_factory.ts | 71 ++ .../actions/action_factory_definition.ts | 46 ++ .../public/actions/action_internal.test.ts | 33 + .../public/actions/action_internal.ts | 58 ++ .../public/actions/create_action.ts | 14 +- .../actions/dynamic_action_manager.test.ts | 646 ++++++++++++++++++ .../public/actions/dynamic_action_manager.ts | 284 ++++++++ .../actions/dynamic_action_manager_state.ts | 111 +++ .../public/actions/dynamic_action_storage.ts | 102 +++ .../ui_actions/public/actions/index.ts | 6 + .../ui_actions/public/actions/types.ts | 24 + .../build_eui_context_menu_panels.tsx | 61 +- src/plugins/ui_actions/public/index.ts | 22 +- src/plugins/ui_actions/public/mocks.ts | 18 +- src/plugins/ui_actions/public/plugin.ts | 8 +- .../public/service/ui_actions_service.test.ts | 114 +++- .../public/service/ui_actions_service.ts | 122 +++- .../tests/execute_trigger_actions.test.ts | 10 +- .../public/tests/get_trigger_actions.test.ts | 9 +- .../get_trigger_compatible_actions.test.ts | 6 +- .../public/tests/test_samples/index.ts | 1 + .../public/triggers/select_range_trigger.ts | 2 +- .../public/triggers/trigger_internal.ts | 1 + .../public/triggers/value_click_trigger.ts | 2 +- src/plugins/ui_actions/public/types.ts | 6 +- .../ui_actions/public/util/configurable.ts | 60 ++ src/plugins/ui_actions/public/util/index.ts | 21 + .../presentable.ts} | 47 +- src/plugins/ui_actions/scripts/storybook.js | 26 + .../public/np_ready/public/plugin.tsx | 3 +- .../public/sample_panel_action.tsx | 3 +- .../public/sample_panel_link.ts | 3 +- x-pack/.i18nrc.json | 1 + .../action_wizard/action_wizard.scss | 5 - .../action_wizard/action_wizard.story.tsx | 24 +- .../action_wizard/action_wizard.test.tsx | 13 +- .../action_wizard/action_wizard.tsx | 95 ++- .../public/components/action_wizard/i18n.ts | 2 +- .../public/components/action_wizard/index.ts | 2 +- .../components/action_wizard/test_data.tsx | 218 +++--- .../public/components/index.ts} | 2 +- .../public/custom_time_range_action.tsx | 2 +- .../advanced_ui_actions/public/index.ts | 14 + .../advanced_ui_actions/public/plugin.ts | 28 +- .../action_factory_service/action_factory.ts | 11 + .../action_factory_definition.ts | 11 + .../services/action_factory_service/index.ts | 8 + .../public/services}/index.ts | 2 +- .../advanced_ui_actions/public/util/index.ts | 10 + x-pack/plugins/dashboard_enhanced/README.md | 1 + x-pack/plugins/dashboard_enhanced/kibana.json | 8 + .../public/components/README.md | 5 + .../dashboard_drilldown_config.story.tsx | 54 ++ .../dashboard_drilldown_config.test.tsx | 11 + .../dashboard_drilldown_config.tsx | 69 ++ .../dashboard_drilldown_config/i18n.ts | 14 + .../dashboard_drilldown_config/index.ts | 7 + .../public/components/index.ts | 7 + .../dashboard_enhanced/public/index.ts | 19 + .../dashboard_enhanced/public/mocks.ts | 27 + .../dashboard_enhanced/public/plugin.ts | 50 ++ .../flyout_create_drilldown.test.tsx | 124 ++++ .../flyout_create_drilldown.tsx | 74 ++ .../actions/flyout_create_drilldown/index.ts | 11 + .../flyout_edit_drilldown.test.tsx | 102 +++ .../flyout_edit_drilldown.tsx | 71 ++ .../actions/flyout_edit_drilldown}/i18n.ts | 6 +- .../actions/flyout_edit_drilldown/index.tsx | 11 + .../flyout_edit_drilldown/menu_item.test.tsx | 37 + .../flyout_edit_drilldown/menu_item.tsx | 30 + .../services/drilldowns}/actions/index.ts | 0 .../drilldowns/actions/test_helpers.ts | 28 + .../dashboard_drilldowns_services.ts | 60 ++ .../collect_config.test.tsx | 9 + .../collect_config.tsx | 55 ++ .../constants.ts | 7 + .../drilldown.test.tsx | 20 + .../drilldown.tsx | 52 ++ .../dashboard_to_dashboard_drilldown/i18n.ts | 11 + .../dashboard_to_dashboard_drilldown/index.ts | 16 + .../dashboard_to_dashboard_drilldown/types.ts | 22 + .../public/services/drilldowns/index.ts | 7 + .../public/services/index.ts | 7 + .../scripts/storybook.js} | 10 +- .../dashboard_enhanced/server/config.ts | 23 + .../dashboard_enhanced/server/index.ts | 12 + x-pack/plugins/drilldowns/kibana.json | 5 +- .../actions/flyout_create_drilldown/index.tsx | 52 -- .../actions/flyout_edit_drilldown/index.tsx | 72 -- ...nnected_flyout_manage_drilldowns.story.tsx | 43 ++ ...onnected_flyout_manage_drilldowns.test.tsx | 221 ++++++ .../connected_flyout_manage_drilldowns.tsx | 332 +++++++++ .../i18n.ts | 88 +++ .../index.ts | 7 + .../test_data.ts | 89 +++ .../drilldown_hello_bar.story.tsx | 16 +- .../drilldown_hello_bar.tsx | 58 +- .../components/drilldown_hello_bar/i18n.ts | 29 + .../drilldown_picker/drilldown_picker.tsx | 21 - .../flyout_create_drilldown.story.tsx | 24 - .../flyout_create_drilldown.tsx | 34 - .../flyout_drilldown_wizard.story.tsx | 70 ++ .../flyout_drilldown_wizard.tsx | 139 ++++ .../flyout_drilldown_wizard/i18n.ts | 42 ++ .../flyout_drilldown_wizard/index.ts | 7 + .../flyout_frame/flyout_frame.story.tsx | 7 + .../flyout_frame/flyout_frame.test.tsx | 4 +- .../components/flyout_frame/flyout_frame.tsx | 31 +- .../public/components/flyout_frame/i18n.ts | 6 +- .../flyout_list_manage_drilldowns.story.tsx | 22 + .../flyout_list_manage_drilldowns.tsx | 46 ++ .../flyout_list_manage_drilldowns/i18n.ts | 14 + .../flyout_list_manage_drilldowns/index.ts | 7 + .../form_create_drilldown.story.tsx | 34 - .../form_create_drilldown.tsx | 52 -- .../form_drilldown_wizard.story.tsx | 29 + .../form_drilldown_wizard.test.tsx} | 20 +- .../form_drilldown_wizard.tsx | 79 +++ .../i18n.ts | 4 +- .../index.tsx | 2 +- .../components/list_manage_drilldowns/i18n.ts | 36 + .../list_manage_drilldowns/index.tsx | 7 + .../list_manage_drilldowns.story.tsx | 19 + .../list_manage_drilldowns.test.tsx | 70 ++ .../list_manage_drilldowns.tsx | 116 ++++ x-pack/plugins/drilldowns/public/index.ts | 10 +- x-pack/plugins/drilldowns/public/mocks.ts | 12 +- x-pack/plugins/drilldowns/public/plugin.ts | 54 +- .../public/service/drilldown_service.ts | 32 - .../public/services/drilldown_service.ts | 79 +++ .../public/{service => services}/index.ts | 0 x-pack/plugins/drilldowns/public/types.ts | 120 ++++ x-pack/plugins/reporting/public/plugin.tsx | 3 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 164 files changed, 5368 insertions(+), 898 deletions(-) create mode 100644 src/plugins/kibana_utils/index.ts create mode 100644 src/plugins/ui_actions/public/actions/action_factory.ts create mode 100644 src/plugins/ui_actions/public/actions/action_factory_definition.ts create mode 100644 src/plugins/ui_actions/public/actions/action_internal.test.ts create mode 100644 src/plugins/ui_actions/public/actions/action_internal.ts create mode 100644 src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts create mode 100644 src/plugins/ui_actions/public/actions/dynamic_action_manager.ts create mode 100644 src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts create mode 100644 src/plugins/ui_actions/public/actions/dynamic_action_storage.ts create mode 100644 src/plugins/ui_actions/public/actions/types.ts create mode 100644 src/plugins/ui_actions/public/util/configurable.ts create mode 100644 src/plugins/ui_actions/public/util/index.ts rename src/plugins/ui_actions/public/{actions/action_definition.ts => util/presentable.ts} (50%) create mode 100644 src/plugins/ui_actions/scripts/storybook.js rename x-pack/plugins/{drilldowns/public/components/drilldown_picker/index.tsx => advanced_ui_actions/public/components/index.ts} (87%) create mode 100644 x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts create mode 100644 x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts create mode 100644 x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts rename x-pack/plugins/{drilldowns/public/components/flyout_create_drilldown => advanced_ui_actions/public/services}/index.ts (84%) create mode 100644 x-pack/plugins/advanced_ui_actions/public/util/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/README.md create mode 100644 x-pack/plugins/dashboard_enhanced/kibana.json create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/README.md create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/mocks.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/plugin.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx rename x-pack/plugins/{drilldowns/public/components/flyout_create_drilldown => dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown}/i18n.ts (64%) create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx rename x-pack/plugins/{drilldowns/public => dashboard_enhanced/public/services/drilldowns}/actions/index.ts (100%) create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/index.ts rename x-pack/plugins/{drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx => dashboard_enhanced/scripts/storybook.js} (53%) create mode 100644 x-pack/plugins/dashboard_enhanced/server/config.ts create mode 100644 x-pack/plugins/dashboard_enhanced/server/index.ts delete mode 100644 x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx delete mode 100644 x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts create mode 100644 x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts delete mode 100644 x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts delete mode 100644 x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx rename x-pack/plugins/drilldowns/public/components/{form_create_drilldown/form_create_drilldown.test.tsx => form_drilldown_wizard/form_drilldown_wizard.test.tsx} (70%) create mode 100644 x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx rename x-pack/plugins/drilldowns/public/components/{form_create_drilldown => form_drilldown_wizard}/i18n.ts (89%) rename x-pack/plugins/drilldowns/public/components/{form_create_drilldown => form_drilldown_wizard}/index.tsx (85%) create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx delete mode 100644 x-pack/plugins/drilldowns/public/service/drilldown_service.ts create mode 100644 x-pack/plugins/drilldowns/public/services/drilldown_service.ts rename x-pack/plugins/drilldowns/public/{service => services}/index.ts (100%) create mode 100644 x-pack/plugins/drilldowns/public/types.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2db898fab68bf..d48b29c89ece6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,6 +3,7 @@ # For more info, see https://help.github.com/articles/about-codeowners/ # App +/x-pack/legacy/plugins/dashboard_enhanced/ @elastic/kibana-app /x-pack/legacy/plugins/lens/ @elastic/kibana-app /x-pack/legacy/plugins/graph/ @elastic/kibana-app /src/legacy/server/url_shortening/ @elastic/kibana-app diff --git a/examples/ui_action_examples/public/plugin.ts b/examples/ui_action_examples/public/plugin.ts index c47746d4b3fd6..d053f7e82862c 100644 --- a/examples/ui_action_examples/public/plugin.ts +++ b/examples/ui_action_examples/public/plugin.ts @@ -46,7 +46,7 @@ export class UiActionExamplesPlugin })); uiActions.registerAction(helloWorldAction); - uiActions.attachAction(helloWorldTrigger.id, helloWorldAction); + uiActions.addTriggerAction(helloWorldTrigger.id, helloWorldAction); } public start() {} diff --git a/examples/ui_actions_explorer/public/app.tsx b/examples/ui_actions_explorer/public/app.tsx index 462f5c3bf88ba..f08b8bb29bdd3 100644 --- a/examples/ui_actions_explorer/public/app.tsx +++ b/examples/ui_actions_explorer/public/app.tsx @@ -95,8 +95,7 @@ const ActionsExplorer = ({ uiActionsApi, openModal }: Props) => { ); }, }); - uiActionsApi.registerAction(dynamicAction); - uiActionsApi.attachAction(HELLO_WORLD_TRIGGER_ID, dynamicAction); + uiActionsApi.addTriggerAction(HELLO_WORLD_TRIGGER_ID, dynamicAction); setConfirmationText( `You've successfully added a new action: ${dynamicAction.getDisplayName( {} diff --git a/examples/ui_actions_explorer/public/plugin.tsx b/examples/ui_actions_explorer/public/plugin.tsx index f1895905a45e1..de86b51aee3a8 100644 --- a/examples/ui_actions_explorer/public/plugin.tsx +++ b/examples/ui_actions_explorer/public/plugin.tsx @@ -79,21 +79,21 @@ export class UiActionsExplorerPlugin implements Plugin (await startServices)[1].uiActions) ); - deps.uiActions.attachAction( + deps.uiActions.addTriggerAction( USER_TRIGGER, createEditUserAction(async () => (await startServices)[0].overlays.openModal) ); - deps.uiActions.attachAction(COUNTRY_TRIGGER, viewInMapsAction); - deps.uiActions.attachAction(COUNTRY_TRIGGER, lookUpWeatherAction); - deps.uiActions.attachAction(COUNTRY_TRIGGER, showcasePluggability); - deps.uiActions.attachAction(PHONE_TRIGGER, makePhoneCallAction); - deps.uiActions.attachAction(PHONE_TRIGGER, showcasePluggability); - deps.uiActions.attachAction(USER_TRIGGER, showcasePluggability); + deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, viewInMapsAction); + deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, lookUpWeatherAction); + deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, showcasePluggability); + deps.uiActions.addTriggerAction(PHONE_TRIGGER, makePhoneCallAction); + deps.uiActions.addTriggerAction(PHONE_TRIGGER, showcasePluggability); + deps.uiActions.addTriggerAction(USER_TRIGGER, showcasePluggability); core.application.register({ id: 'uiActionsExplorer', diff --git a/src/core/public/overlays/flyout/flyout_service.tsx b/src/core/public/overlays/flyout/flyout_service.tsx index b609b2ce1d741..444430175d4f2 100644 --- a/src/core/public/overlays/flyout/flyout_service.tsx +++ b/src/core/public/overlays/flyout/flyout_service.tsx @@ -91,6 +91,7 @@ export interface OverlayFlyoutStart { export interface OverlayFlyoutOpenOptions { className?: string; closeButtonAriaLabel?: string; + ownFocus?: boolean; 'data-test-subj'?: string; } diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 8ed64f004c9be..370abc120d475 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -18,12 +18,14 @@ */ export const storybookAliases = { + advanced_ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js', apm: 'x-pack/legacy/plugins/apm/scripts/storybook.js', canvas: 'x-pack/legacy/plugins/canvas/scripts/storybook_new.js', codeeditor: 'src/plugins/kibana_react/public/code_editor/scripts/storybook.ts', + dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/scripts/storybook.js', drilldowns: 'x-pack/plugins/drilldowns/scripts/storybook.js', embeddable: 'src/plugins/embeddable/scripts/storybook.js', infra: 'x-pack/legacy/plugins/infra/scripts/storybook.js', siem: 'x-pack/legacy/plugins/siem/scripts/storybook.js', - ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js', + ui_actions: 'src/plugins/ui_actions/scripts/storybook.js', }; diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts index 342824bade3dd..4b21be83f1722 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts @@ -45,6 +45,7 @@ import { PersistedState } from '../../../../../../../plugins/visualizations/publ import { buildPipeline } from '../legacy/build_pipeline'; import { Vis } from '../vis'; import { getExpressions, getUiActions } from '../services'; +import { VisualizationsStartDeps } from '../plugin'; import { VIS_EVENT_TO_TRIGGER } from './events'; const getKeys = (o: T): Array => Object.keys(o) as Array; @@ -56,6 +57,7 @@ export interface VisualizeEmbeddableConfiguration { editable: boolean; appState?: { save(): void }; uiState?: PersistedState; + uiActions?: VisualizationsStartDeps['uiActions']; } export interface VisualizeInput extends EmbeddableInput { @@ -94,7 +96,7 @@ export class VisualizeEmbeddable extends Embeddable { public readonly type = VISUALIZE_EMBEDDABLE_TYPE; - constructor() { + constructor( + private readonly getUiActions: () => Promise< + Pick['uiActions'] + > + ) { super({ savedObjectMetaData: { name: i18n.translate('visualizations.savedObjectName', { defaultMessage: 'Visualization' }), @@ -114,6 +119,8 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory< const indexPattern = vis.data.indexPattern; const indexPatterns = indexPattern ? [indexPattern] : []; + const uiActions = await this.getUiActions(); + const editable = await this.isEditable(); return new VisualizeEmbeddable( getTimeFilter(), @@ -124,6 +131,7 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory< editable, appState: input.appState, uiState: input.uiState, + uiActions, }, input, parent diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts index 17f777e4e80e1..dcd11c920f17c 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts @@ -17,7 +17,7 @@ * under the License. */ -import { PluginInitializerContext } from '../../../../../../core/public'; +import { CoreSetup, PluginInitializerContext } from '../../../../../../core/public'; import { VisualizationsSetup, VisualizationsStart } from './'; import { VisualizationsPlugin } from './plugin'; import { coreMock } from '../../../../../../core/public/mocks'; @@ -26,6 +26,7 @@ import { expressionsPluginMock } from '../../../../../../plugins/expressions/pub import { dataPluginMock } from '../../../../../../plugins/data/public/mocks'; import { usageCollectionPluginMock } from '../../../../../../plugins/usage_collection/public/mocks'; import { uiActionsPluginMock } from '../../../../../../plugins/ui_actions/public/mocks'; +import { VisualizationsStartDeps } from './plugin'; const createSetupContract = (): VisualizationsSetup => ({ createBaseVisualization: jest.fn(), @@ -48,7 +49,7 @@ const createStartContract = (): VisualizationsStart => ({ const createInstance = async () => { const plugin = new VisualizationsPlugin({} as PluginInitializerContext); - const setup = plugin.setup(coreMock.createSetup(), { + const setup = plugin.setup(coreMock.createSetup() as CoreSetup, { data: dataPluginMock.createSetupContract(), expressions: expressionsPluginMock.createSetupContract(), embeddable: embeddablePluginMock.createSetupContract(), diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts index 3ade6cee0d4d2..c826841e2bcf3 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts @@ -111,7 +111,7 @@ export class VisualizationsPlugin constructor(initializerContext: PluginInitializerContext) {} public setup( - core: CoreSetup, + core: CoreSetup, { expressions, embeddable, usageCollection, data }: VisualizationsSetupDeps ): VisualizationsSetup { setUISettings(core.uiSettings); @@ -120,7 +120,9 @@ export class VisualizationsPlugin expressions.registerFunction(visualizationFunction); expressions.registerRenderer(visualizationRenderer); - const embeddableFactory = new VisualizeEmbeddableFactory(); + const embeddableFactory = new VisualizeEmbeddableFactory( + async () => (await core.getStartServices())[1].uiActions + ); embeddable.registerEmbeddableFactory(VISUALIZE_EMBEDDABLE_TYPE, embeddableFactory); return { diff --git a/src/plugins/dashboard/public/actions/replace_panel_action.tsx b/src/plugins/dashboard/public/actions/replace_panel_action.tsx index 21ec961917d17..4e20aa3c35088 100644 --- a/src/plugins/dashboard/public/actions/replace_panel_action.tsx +++ b/src/plugins/dashboard/public/actions/replace_panel_action.tsx @@ -37,7 +37,7 @@ export interface ReplacePanelActionContext { export class ReplacePanelAction implements ActionByType { public readonly type = ACTION_REPLACE_PANEL; public readonly id = ACTION_REPLACE_PANEL; - public order = 11; + public order = 3; constructor( private core: CoreStart, diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 8a6e747aac170..d663c736e5aed 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -78,7 +78,7 @@ export class DashboardEmbeddableContainerPublicPlugin ): Setup { const expandPanelAction = new ExpandPanelAction(); uiActions.registerAction(expandPanelAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id); const startServices = core.getStartServices(); if (share) { @@ -134,7 +134,7 @@ export class DashboardEmbeddableContainerPublicPlugin plugins.embeddable.getEmbeddableFactories ); uiActions.registerAction(changeViewAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, changeViewAction); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, changeViewAction); } public stop() {} diff --git a/src/plugins/dashboard/public/tests/dashboard_container.test.tsx b/src/plugins/dashboard/public/tests/dashboard_container.test.tsx index a81d80b440e04..4aede3f3442fb 100644 --- a/src/plugins/dashboard/public/tests/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/tests/dashboard_container.test.tsx @@ -49,7 +49,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { const editModeAction = createEditModeAction(); uiActionsSetup.registerAction(editModeAction); - uiActionsSetup.attachAction(CONTEXT_MENU_TRIGGER, editModeAction); + uiActionsSetup.addTriggerAction(CONTEXT_MENU_TRIGGER, editModeAction); setup.registerEmbeddableFactory( CONTACT_CARD_EMBEDDABLE, new ContactCardEmbeddableFactory({} as any, (() => null) as any, {} as any) diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index fc5dde94fa851..ea2e85947aa12 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -109,12 +109,12 @@ export class DataPublicPlugin implements Plugin { public readonly type = ACTION_EDIT_PANEL; public readonly id = ACTION_EDIT_PANEL; - public order = 15; + public order = 50; constructor(private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']) {} diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index eb10c16806640..35973cc16cf9b 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -16,23 +16,35 @@ * specific language governing permissions and limitations * under the License. */ -import { isEqual, cloneDeep } from 'lodash'; +import { cloneDeep, isEqual } from 'lodash'; import * as Rx from 'rxjs'; -import { Adapters } from '../types'; +import { Adapters, ViewMode } from '../types'; import { IContainer } from '../containers'; -import { IEmbeddable, EmbeddableInput, EmbeddableOutput } from './i_embeddable'; -import { ViewMode } from '../types'; +import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable'; import { TriggerContextMapping } from '../ui_actions'; import { EmbeddableActionStorage } from './embeddable_action_storage'; +import { + UiActionsDynamicActionManager, + UiActionsStart, +} from '../../../../../plugins/ui_actions/public'; +import { EmbeddableContext } from '../triggers'; function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) { return input.hidePanelTitles ? '' : input.title === undefined ? output.defaultTitle : input.title; } +export interface EmbeddableParams { + uiActions?: UiActionsStart; +} + export abstract class Embeddable< TEmbeddableInput extends EmbeddableInput = EmbeddableInput, TEmbeddableOutput extends EmbeddableOutput = EmbeddableOutput > implements IEmbeddable { + static runtimeId: number = 0; + + public readonly runtimeId = Embeddable.runtimeId++; + public readonly parent?: IContainer; public readonly isContainer: boolean = false; public abstract readonly type: string; @@ -48,15 +60,34 @@ export abstract class Embeddable< // to update input when the parent changes. private parentSubscription?: Rx.Subscription; + private storageSubscription?: Rx.Subscription; + // TODO: Rename to destroyed. private destoyed: boolean = false; - private __actionStorage?: EmbeddableActionStorage; - public get actionStorage(): EmbeddableActionStorage { - return this.__actionStorage || (this.__actionStorage = new EmbeddableActionStorage(this)); + private storage = new EmbeddableActionStorage((this as unknown) as Embeddable); + + private cachedDynamicActions?: UiActionsDynamicActionManager; + public get dynamicActions(): UiActionsDynamicActionManager | undefined { + if (!this.params.uiActions) return undefined; + if (!this.cachedDynamicActions) { + this.cachedDynamicActions = new UiActionsDynamicActionManager({ + isCompatible: async (context: unknown) => + (context as EmbeddableContext).embeddable.runtimeId === this.runtimeId, + storage: this.storage, + uiActions: this.params.uiActions, + }); + } + + return this.cachedDynamicActions; } - constructor(input: TEmbeddableInput, output: TEmbeddableOutput, parent?: IContainer) { + constructor( + input: TEmbeddableInput, + output: TEmbeddableOutput, + parent?: IContainer, + public readonly params: EmbeddableParams = {} + ) { this.id = input.id; this.output = { title: getPanelTitle(input, output), @@ -80,6 +111,18 @@ export abstract class Embeddable< this.onResetInput(newInput); }); } + + if (this.dynamicActions) { + this.dynamicActions.start().catch(error => { + /* eslint-disable */ + console.log('Failed to start embeddable dynamic actions', this); + console.error(error); + /* eslint-enable */ + }); + this.storageSubscription = this.input$.subscribe(() => { + this.storage.reload$.next(); + }); + } } public getIsContainer(): this is IContainer { @@ -158,6 +201,20 @@ export abstract class Embeddable< */ public destroy(): void { this.destoyed = true; + + if (this.dynamicActions) { + this.dynamicActions.stop().catch(error => { + /* eslint-disable */ + console.log('Failed to stop embeddable dynamic actions', this); + console.error(error); + /* eslint-enable */ + }); + } + + if (this.storageSubscription) { + this.storageSubscription.unsubscribe(); + } + if (this.parentSubscription) { this.parentSubscription.unsubscribe(); } diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts index 56facc37fc666..83fd3f184e098 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts @@ -20,7 +20,8 @@ import { Embeddable } from './embeddable'; import { EmbeddableInput } from './i_embeddable'; import { ViewMode } from '../types'; -import { EmbeddableActionStorage, SerializedEvent } from './embeddable_action_storage'; +import { EmbeddableActionStorage } from './embeddable_action_storage'; +import { UiActionsSerializedEvent } from '../../../../ui_actions/public'; import { of } from '../../../../kibana_utils/common'; class TestEmbeddable extends Embeddable { @@ -42,9 +43,9 @@ describe('EmbeddableActionStorage', () => { test('can add event to embeddable', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -57,23 +58,40 @@ describe('EmbeddableActionStorage', () => { expect(events2).toEqual([event]); }); + test('does not merge .getInput() into .updateInput()', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + const event: UiActionsSerializedEvent = { + eventId: 'EVENT_ID', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + + const spy = jest.spyOn(embeddable, 'updateInput'); + + await storage.create(event); + + expect(spy.mock.calls[0][0].id).toBe(undefined); + expect(spy.mock.calls[0][0].viewMode).toBe(undefined); + }); + test('can create multiple events', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event3: SerializedEvent = { + const event3: UiActionsSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -95,9 +113,9 @@ describe('EmbeddableActionStorage', () => { test('throws when creating an event with the same ID', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -122,16 +140,16 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'foo', } as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'bar', } as any, @@ -148,30 +166,30 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'foo', } as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'bar', } as any, }; - const event22: SerializedEvent = { + const event22: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'baz', } as any, }; - const event3: SerializedEvent = { + const event3: UiActionsSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'qux', } as any, @@ -199,9 +217,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -217,14 +235,14 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -249,9 +267,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -266,23 +284,23 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'foo', } as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'bar', } as any, }; - const event3: SerializedEvent = { + const event3: UiActionsSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'qux', } as any, @@ -327,9 +345,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -355,9 +373,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -383,9 +401,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -402,19 +420,19 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID2', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event3: SerializedEvent = { + const event3: UiActionsSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID3', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -458,7 +476,7 @@ describe('EmbeddableActionStorage', () => { await storage.create({ eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }); @@ -466,7 +484,7 @@ describe('EmbeddableActionStorage', () => { await storage.create({ eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }); @@ -502,15 +520,15 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts index 520f92840c5f9..fad5b4d535d6c 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts @@ -17,32 +17,20 @@ * under the License. */ +import { + UiActionsAbstractActionStorage, + UiActionsSerializedEvent, +} from '../../../../ui_actions/public'; import { Embeddable } from '..'; -/** - * Below two interfaces are here temporarily, they will move to `ui_actions` - * plugin once #58216 is merged. - */ -export interface SerializedEvent { - eventId: string; - triggerId: string; - action: unknown; -} -export interface ActionStorage { - create(event: SerializedEvent): Promise; - update(event: SerializedEvent): Promise; - remove(eventId: string): Promise; - read(eventId: string): Promise; - count(): Promise; - list(): Promise; -} - -export class EmbeddableActionStorage implements ActionStorage { - constructor(private readonly embbeddable: Embeddable) {} +export class EmbeddableActionStorage extends UiActionsAbstractActionStorage { + constructor(private readonly embbeddable: Embeddable) { + super(); + } - async create(event: SerializedEvent) { + async create(event: UiActionsSerializedEvent) { const input = this.embbeddable.getInput(); - const events = (input.events || []) as SerializedEvent[]; + const events = (input.events || []) as UiActionsSerializedEvent[]; const exists = !!events.find(({ eventId }) => eventId === event.eventId); if (exists) { @@ -53,14 +41,13 @@ export class EmbeddableActionStorage implements ActionStorage { } this.embbeddable.updateInput({ - ...input, events: [...events, event], }); } - async update(event: SerializedEvent) { + async update(event: UiActionsSerializedEvent) { const input = this.embbeddable.getInput(); - const events = (input.events || []) as SerializedEvent[]; + const events = (input.events || []) as UiActionsSerializedEvent[]; const index = events.findIndex(({ eventId }) => eventId === event.eventId); if (index === -1) { @@ -72,14 +59,13 @@ export class EmbeddableActionStorage implements ActionStorage { } this.embbeddable.updateInput({ - ...input, events: [...events.slice(0, index), event, ...events.slice(index + 1)], }); } async remove(eventId: string) { const input = this.embbeddable.getInput(); - const events = (input.events || []) as SerializedEvent[]; + const events = (input.events || []) as UiActionsSerializedEvent[]; const index = events.findIndex(event => eventId === event.eventId); if (index === -1) { @@ -91,14 +77,13 @@ export class EmbeddableActionStorage implements ActionStorage { } this.embbeddable.updateInput({ - ...input, events: [...events.slice(0, index), ...events.slice(index + 1)], }); } - async read(eventId: string): Promise { + async read(eventId: string): Promise { const input = this.embbeddable.getInput(); - const events = (input.events || []) as SerializedEvent[]; + const events = (input.events || []) as UiActionsSerializedEvent[]; const event = events.find(ev => eventId === ev.eventId); if (!event) { @@ -113,14 +98,10 @@ export class EmbeddableActionStorage implements ActionStorage { private __list() { const input = this.embbeddable.getInput(); - return (input.events || []) as SerializedEvent[]; - } - - async count(): Promise { - return this.__list().length; + return (input.events || []) as UiActionsSerializedEvent[]; } - async list(): Promise { + async list(): Promise { return this.__list(); } } diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index 6345c34b0dda2..9a4452aceba00 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -18,6 +18,7 @@ */ import { Observable } from 'rxjs'; +import { UiActionsDynamicActionManager } from '../../../../../plugins/ui_actions/public'; import { Adapters } from '../types'; import { IContainer } from '../containers/i_container'; import { ViewMode } from '../types'; @@ -33,7 +34,7 @@ export interface EmbeddableInput { /** * Reserved key for `ui_actions` events. */ - events?: unknown; + events?: Array<{ eventId: string }>; /** * List of action IDs that this embeddable should not render. @@ -82,6 +83,19 @@ export interface IEmbeddable< **/ readonly id: string; + /** + * Unique ID an embeddable is assigned each time it is initialized. This ID + * is different for different instances of the same embeddable. For example, + * if the same dashboard is rendered twice on the screen, all embeddable + * instances will have a unique `runtimeId`. + */ + readonly runtimeId?: number; + + /** + * Default implementation of dynamic action API for embeddables. + */ + dynamicActions?: UiActionsDynamicActionManager; + /** * A functional representation of the isContainer variable, but helpful for typescript to * know the shape if this returns true diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 757d4e6bfddef..83d3d5e10761b 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -44,7 +44,7 @@ import { import { inspectorPluginMock } from 'src/plugins/inspector/public/mocks'; import { EuiBadge } from '@elastic/eui'; -const actionRegistry = new Map>(); +const actionRegistry = new Map(); const triggerRegistry = new Map(); const embeddableFactories = new Map(); const getEmbeddableFactory = (id: string) => embeddableFactories.get(id); @@ -213,13 +213,17 @@ const renderInEditModeAndOpenContextMenu = async ( }; test('HelloWorldContainer in edit mode hides disabledActions', async () => { - const action: Action = { + const action = { id: 'FOO', type: 'FOO' as ActionType, getIconType: () => undefined, getDisplayName: () => 'foo', isCompatible: async () => true, execute: async () => {}, + order: 10, + getHref: () => { + return undefined; + }, }; const getActions = () => Promise.resolve([action]); @@ -245,13 +249,17 @@ test('HelloWorldContainer in edit mode hides disabledActions', async () => { }); test('HelloWorldContainer hides disabled badges', async () => { - const action: Action = { + const action = { id: 'BAR', type: 'BAR' as ActionType, getIconType: () => undefined, getDisplayName: () => 'bar', isCompatible: async () => true, execute: async () => {}, + order: 10, + getHref: () => { + return undefined; + }, }; const getActions = () => Promise.resolve([action]); diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index b95060a73252f..c6537f2d94994 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -38,6 +38,14 @@ import { EditPanelAction } from '../actions'; import { CustomizePanelModal } from './panel_header/panel_actions/customize_title/customize_panel_modal'; import { EmbeddableStart } from '../../plugin'; +const sortByOrderField = ( + { order: orderA }: { order?: number }, + { order: orderB }: { order?: number } +) => (orderB || 0) - (orderA || 0); + +const removeById = (disabledActions: string[]) => ({ id }: { id: string }) => + disabledActions.indexOf(id) === -1; + interface Props { embeddable: IEmbeddable; getActions: UiActionsService['getTriggerCompatibleActions']; @@ -57,12 +65,14 @@ interface State { hidePanelTitles: boolean; closeContextMenu: boolean; badges: Array>; + eventCount?: number; } export class EmbeddablePanel extends React.Component { private embeddableRoot: React.RefObject; private parentSubscription?: Subscription; private subscription?: Subscription; + private eventCountSubscription?: Subscription; private mounted: boolean = false; private generateId = htmlIdGenerator(); @@ -136,6 +146,9 @@ export class EmbeddablePanel extends React.Component { if (this.subscription) { this.subscription.unsubscribe(); } + if (this.eventCountSubscription) { + this.eventCountSubscription.unsubscribe(); + } if (this.parentSubscription) { this.parentSubscription.unsubscribe(); } @@ -177,6 +190,7 @@ export class EmbeddablePanel extends React.Component { badges={this.state.badges} embeddable={this.props.embeddable} headerId={headerId} + eventCount={this.state.eventCount} /> )}
@@ -188,6 +202,15 @@ export class EmbeddablePanel extends React.Component { if (this.embeddableRoot.current) { this.props.embeddable.render(this.embeddableRoot.current); } + + const dynamicActions = this.props.embeddable.dynamicActions; + if (dynamicActions) { + this.setState({ eventCount: dynamicActions.state.get().events.length }); + this.eventCountSubscription = dynamicActions.state.state$.subscribe(({ events }) => { + if (!this.mounted) return; + this.setState({ eventCount: events.length }); + }); + } } closeMyContextMenuPanel = () => { @@ -201,13 +224,14 @@ export class EmbeddablePanel extends React.Component { }; private getActionContextMenuPanel = async () => { - let actions = await this.props.getActions(CONTEXT_MENU_TRIGGER, { + let regularActions = await this.props.getActions(CONTEXT_MENU_TRIGGER, { embeddable: this.props.embeddable, }); const { disabledActions } = this.props.embeddable.getInput(); if (disabledActions) { - actions = actions.filter(action => disabledActions.indexOf(action.id) === -1); + const removeDisabledActions = removeById(disabledActions); + regularActions = regularActions.filter(removeDisabledActions); } const createGetUserData = (overlays: OverlayStart) => @@ -246,16 +270,10 @@ export class EmbeddablePanel extends React.Component { new EditPanelAction(this.props.getEmbeddableFactory), ]; - const sorted = actions - .concat(extraActions) - .sort((a: Action, b: Action) => { - const bOrder = b.order || 0; - const aOrder = a.order || 0; - return bOrder - aOrder; - }); + const sortedActions = [...regularActions, ...extraActions].sort(sortByOrderField); return await buildContextMenuForActions({ - actions: sorted, + actions: sortedActions, actionContext: { embeddable: this.props.embeddable }, closeMenu: this.closeMyContextMenuPanel, }); diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts index c0e43c0538833..36957c3b79491 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts @@ -33,15 +33,13 @@ interface ActionContext { export class CustomizePanelTitleAction implements Action { public readonly type = ACTION_CUSTOMIZE_PANEL; public id = ACTION_CUSTOMIZE_PANEL; - public order = 10; + public order = 40; - constructor(private readonly getDataFromUser: GetUserData) { - this.order = 10; - } + constructor(private readonly getDataFromUser: GetUserData) {} public getDisplayName() { return i18n.translate('embeddableApi.customizePanel.action.displayName', { - defaultMessage: 'Customize panel', + defaultMessage: 'Edit panel title', }); } diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts index d04f35715537c..ae9645767b267 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts @@ -31,7 +31,7 @@ interface ActionContext { export class InspectPanelAction implements Action { public readonly type = ACTION_INSPECT_PANEL; public readonly id = ACTION_INSPECT_PANEL; - public order = 10; + public order = 20; constructor(private readonly inspector: InspectorStartContract) {} diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts index ee7948f3d6a4a..a6d4128f3f106 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts @@ -41,7 +41,7 @@ function hasExpandedPanelInput( export class RemovePanelAction implements Action { public readonly type = REMOVE_PANEL_ACTION; public readonly id = REMOVE_PANEL_ACTION; - public order = 5; + public order = 1; constructor() {} diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index 99516a1d21d6f..2a856af7ae916 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -23,6 +23,7 @@ import { EuiIcon, EuiToolTip, EuiScreenReaderOnly, + EuiNotificationBadge, } from '@elastic/eui'; import classNames from 'classnames'; import React from 'react'; @@ -40,6 +41,7 @@ export interface PanelHeaderProps { badges: Array>; embeddable: IEmbeddable; headerId?: string; + eventCount?: number; } function renderBadges(badges: Array>, embeddable: IEmbeddable) { @@ -90,6 +92,7 @@ export function PanelHeader({ badges, embeddable, headerId, + eventCount, }: PanelHeaderProps) { const viewDescription = getViewDescription(embeddable); const showTitle = !isViewMode || (title && !hidePanelTitles) || viewDescription !== ''; @@ -147,7 +150,11 @@ export function PanelHeader({ )} {renderBadges(badges, embeddable)} - + {!isViewMode && !!eventCount && ( + + {eventCount} + + )} >( + container: Container +): UnboxState => useObservable(container.state$, container.get()); + +/** + * Apply selector to state container to extract only needed information. Will + * re-render your component only when the section changes. + * + * @param container State container which state to track. + * @param selector Function used to pick parts of state. + * @param comparator Comparator function used to memoize previous result, to not + * re-render React component if state did not change. By default uses + * `fast-deep-equal` package. + */ +export const useContainerSelector = , Result>( + container: Container, + selector: (state: UnboxState) => Result, + comparator: Comparator = defaultComparator +): Result => { + const { state$, get } = container; + const lastValueRef = useRef(get()); + const [value, setValue] = React.useState(() => { + const newValue = selector(get()); + lastValueRef.current = newValue; + return newValue; + }); + useLayoutEffect(() => { + const subscription = state$.subscribe((currentState: UnboxState) => { + const newValue = selector(currentState); + if (!comparator(lastValueRef.current, newValue)) { + lastValueRef.current = newValue; + setValue(newValue); + } + }); + return () => subscription.unsubscribe(); + }, [state$, comparator]); + return value; +}; + export const createStateContainerReactHelpers = >() => { const context = React.createContext(null as any); const useContainer = (): Container => useContext(context); const useState = (): UnboxState => { - const { state$, get } = useContainer(); - const value = useObservable(state$, get()); - return value; + const container = useContainer(); + return useContainerState(container); }; const useTransitions: () => Container['transitions'] = () => useContainer().transitions; @@ -41,24 +84,8 @@ export const createStateContainerReactHelpers = ) => Result, comparator: Comparator = defaultComparator ): Result => { - const { state$, get } = useContainer(); - const lastValueRef = useRef(get()); - const [value, setValue] = React.useState(() => { - const newValue = selector(get()); - lastValueRef.current = newValue; - return newValue; - }); - useLayoutEffect(() => { - const subscription = state$.subscribe((currentState: UnboxState) => { - const newValue = selector(currentState); - if (!comparator(lastValueRef.current, newValue)) { - lastValueRef.current = newValue; - setValue(newValue); - } - }); - return () => subscription.unsubscribe(); - }, [state$, comparator]); - return value; + const container = useContainer(); + return useContainerSelector(container, selector, comparator); }; const connect: Connect> = mapStateToProp => component => props => diff --git a/src/plugins/kibana_utils/common/state_containers/types.ts b/src/plugins/kibana_utils/common/state_containers/types.ts index 26a29bc470e8a..29ffa4cd486b5 100644 --- a/src/plugins/kibana_utils/common/state_containers/types.ts +++ b/src/plugins/kibana_utils/common/state_containers/types.ts @@ -43,7 +43,7 @@ export interface BaseStateContainer { export interface StateContainer< State extends BaseState, - PureTransitions extends object, + PureTransitions extends object = object, PureSelectors extends object = {} > extends BaseStateContainer { transitions: Readonly>; diff --git a/src/plugins/kibana_utils/index.ts b/src/plugins/kibana_utils/index.ts new file mode 100644 index 0000000000000..14d6e52dc0465 --- /dev/null +++ b/src/plugins/kibana_utils/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { createStateContainer, StateContainer, of } from './common'; diff --git a/src/plugins/ui_actions/public/actions/action.ts b/src/plugins/ui_actions/public/actions/action.ts index 2b2fc004a84c6..15f1d6dd79289 100644 --- a/src/plugins/ui_actions/public/actions/action.ts +++ b/src/plugins/ui_actions/public/actions/action.ts @@ -19,10 +19,12 @@ import { UiComponent } from 'src/plugins/kibana_utils/common'; import { ActionType, ActionContextMapping } from '../types'; +import { Presentable } from '../util/presentable'; export type ActionByType = Action; -export interface Action { +export interface Action + extends Partial> { /** * Determined the order when there is more than one action matched to a trigger. * Higher numbers are displayed first. @@ -63,12 +65,30 @@ export interface Action { isCompatible(context: Context): Promise; /** - * If this returns something truthy, this is used in addition to the `execute` method when clicked. + * Executes the action. */ - getHref?(context: Context): string | undefined; + execute(context: Context): Promise; +} + +/** + * A convenience interface used to register an action. + */ +export interface ActionDefinition + extends Partial> { + /** + * ID of the action that uniquely identifies this action in the actions registry. + */ + readonly id: string; + + /** + * ID of the factory for this action. Used to construct dynamic actions. + */ + readonly type?: ActionType; /** * Executes the action. */ execute(context: Context): Promise; } + +export type ActionContext = A extends ActionDefinition ? Context : never; diff --git a/src/plugins/ui_actions/public/actions/action_factory.ts b/src/plugins/ui_actions/public/actions/action_factory.ts new file mode 100644 index 0000000000000..bc0ec844d00f5 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/action_factory.ts @@ -0,0 +1,71 @@ +/* + * 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 { uiToReactComponent } from '../../../kibana_react/public'; +import { Presentable } from '../util/presentable'; +import { ActionDefinition } from './action'; +import { ActionFactoryDefinition } from './action_factory_definition'; +import { Configurable } from '../util'; +import { SerializedAction } from './types'; + +export class ActionFactory< + Config extends object = object, + FactoryContext extends object = object, + ActionContext extends object = object +> implements Presentable, Configurable { + constructor( + protected readonly def: ActionFactoryDefinition + ) {} + + public readonly id = this.def.id; + public readonly order = this.def.order || 0; + public readonly MenuItem? = this.def.MenuItem; + public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined; + + public readonly CollectConfig = this.def.CollectConfig; + public readonly ReactCollectConfig = uiToReactComponent(this.CollectConfig); + public readonly createConfig = this.def.createConfig; + public readonly isConfigValid = this.def.isConfigValid; + + public getIconType(context: FactoryContext): string | undefined { + if (!this.def.getIconType) return undefined; + return this.def.getIconType(context); + } + + public getDisplayName(context: FactoryContext): string { + if (!this.def.getDisplayName) return ''; + return this.def.getDisplayName(context); + } + + public async isCompatible(context: FactoryContext): Promise { + if (!this.def.isCompatible) return true; + return await this.def.isCompatible(context); + } + + public getHref(context: FactoryContext): string | undefined { + if (!this.def.getHref) return undefined; + return this.def.getHref(context); + } + + public create( + serializedAction: Omit, 'factoryId'> + ): ActionDefinition { + return this.def.create(serializedAction); + } +} diff --git a/src/plugins/ui_actions/public/actions/action_factory_definition.ts b/src/plugins/ui_actions/public/actions/action_factory_definition.ts new file mode 100644 index 0000000000000..7ac94a41e7076 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/action_factory_definition.ts @@ -0,0 +1,46 @@ +/* + * 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 { ActionDefinition } from './action'; +import { Presentable, Configurable } from '../util'; +import { SerializedAction } from './types'; + +/** + * This is a convenience interface for registering new action factories. + */ +export interface ActionFactoryDefinition< + Config extends object = object, + FactoryContext extends object = object, + ActionContext extends object = object +> extends Partial>, Configurable { + /** + * Unique ID of the action factory. This ID is used to identify this action + * factory in the registry as well as to construct actions of this type and + * identify this action factory when presenting it to the user in UI. + */ + id: string; + + /** + * This method should return a definition of a new action, normally used to + * register it in `ui_actions` registry. + */ + create( + serializedAction: Omit, 'factoryId'> + ): ActionDefinition; +} diff --git a/src/plugins/ui_actions/public/actions/action_internal.test.ts b/src/plugins/ui_actions/public/actions/action_internal.test.ts new file mode 100644 index 0000000000000..b14346180c274 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/action_internal.test.ts @@ -0,0 +1,33 @@ +/* + * 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 { ActionDefinition } from './action'; +import { ActionInternal } from './action_internal'; + +const defaultActionDef: ActionDefinition = { + id: 'test-action', + execute: jest.fn(), +}; + +describe('ActionInternal', () => { + test('can instantiate from action definition', () => { + const action = new ActionInternal(defaultActionDef); + expect(action.id).toBe('test-action'); + }); +}); diff --git a/src/plugins/ui_actions/public/actions/action_internal.ts b/src/plugins/ui_actions/public/actions/action_internal.ts new file mode 100644 index 0000000000000..245ded991c032 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/action_internal.ts @@ -0,0 +1,58 @@ +/* + * 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 { Action, ActionContext as Context, ActionDefinition } from './action'; +import { Presentable } from '../util/presentable'; +import { uiToReactComponent } from '../../../kibana_react/public'; +import { ActionType } from '../types'; + +export class ActionInternal + implements Action>, Presentable> { + constructor(public readonly definition: A) {} + + public readonly id: string = this.definition.id; + public readonly type: ActionType = this.definition.type || ''; + public readonly order: number = this.definition.order || 0; + public readonly MenuItem? = this.definition.MenuItem; + public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined; + + public execute(context: Context) { + return this.definition.execute(context); + } + + public getIconType(context: Context): string | undefined { + if (!this.definition.getIconType) return undefined; + return this.definition.getIconType(context); + } + + public getDisplayName(context: Context): string { + if (!this.definition.getDisplayName) return `Action: ${this.id}`; + return this.definition.getDisplayName(context); + } + + public async isCompatible(context: Context): Promise { + if (!this.definition.isCompatible) return true; + return await this.definition.isCompatible(context); + } + + public getHref(context: Context): string | undefined { + if (!this.definition.getHref) return undefined; + return this.definition.getHref(context); + } +} diff --git a/src/plugins/ui_actions/public/actions/create_action.ts b/src/plugins/ui_actions/public/actions/create_action.ts index 90a9415c0b497..8f1cd23715d3f 100644 --- a/src/plugins/ui_actions/public/actions/create_action.ts +++ b/src/plugins/ui_actions/public/actions/create_action.ts @@ -17,11 +17,19 @@ * under the License. */ +import { ActionContextMapping } from '../types'; import { ActionByType } from './action'; import { ActionType } from '../types'; -import { ActionDefinition } from './action_definition'; +import { ActionDefinition } from './action'; -export function createAction(action: ActionDefinition): ActionByType { +interface ActionDefinitionByType + extends Omit, 'id'> { + id?: string; +} + +export function createAction( + action: ActionDefinitionByType +): ActionByType { return { getIconType: () => undefined, order: 0, @@ -30,5 +38,5 @@ export function createAction(action: ActionDefinition): getDisplayName: () => '', getHref: () => undefined, ...action, - }; + } as ActionByType; } diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts b/src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts new file mode 100644 index 0000000000000..2574a9e529ebf --- /dev/null +++ b/src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts @@ -0,0 +1,646 @@ +/* + * 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 { DynamicActionManager } from './dynamic_action_manager'; +import { ActionStorage, MemoryActionStorage, SerializedEvent } from './dynamic_action_storage'; +import { UiActionsService } from '../service'; +import { ActionFactoryDefinition } from './action_factory_definition'; +import { ActionRegistry } from '../types'; +import { SerializedAction } from './types'; +import { of } from '../../../kibana_utils'; + +const actionFactoryDefinition1: ActionFactoryDefinition = { + id: 'ACTION_FACTORY_1', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: (() => true) as any, + create: ({ name }) => ({ + id: '', + execute: async () => {}, + getDisplayName: () => name, + }), +}; + +const actionFactoryDefinition2: ActionFactoryDefinition = { + id: 'ACTION_FACTORY_2', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: (() => true) as any, + create: ({ name }) => ({ + id: '', + execute: async () => {}, + getDisplayName: () => name, + }), +}; + +const event1: SerializedEvent = { + eventId: 'EVENT_ID_1', + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition1.id, + name: 'Action 1', + config: {}, + }, +}; + +const event2: SerializedEvent = { + eventId: 'EVENT_ID_2', + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition1.id, + name: 'Action 2', + config: {}, + }, +}; + +const event3: SerializedEvent = { + eventId: 'EVENT_ID_3', + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition2.id, + name: 'Action 3', + config: {}, + }, +}; + +const setup = (events: readonly SerializedEvent[] = []) => { + const isCompatible = async () => true; + const storage: ActionStorage = new MemoryActionStorage(events); + const actions: ActionRegistry = new Map(); + const uiActions = new UiActionsService({ + actions, + }); + const manager = new DynamicActionManager({ + isCompatible, + storage, + uiActions, + }); + + uiActions.registerTrigger({ + id: 'VALUE_CLICK_TRIGGER', + }); + + return { + isCompatible, + actions, + storage, + uiActions, + manager, + }; +}; + +describe('DynamicActionManager', () => { + test('can instantiate', () => { + const { manager } = setup([event1]); + expect(manager).toBeInstanceOf(DynamicActionManager); + }); + + describe('.start()', () => { + test('instantiates stored events', async () => { + const { manager, actions, uiActions } = setup([event1]); + const create1 = jest.fn(); + const create2 = jest.fn(); + + uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); + uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); + + expect(create1).toHaveBeenCalledTimes(0); + expect(create2).toHaveBeenCalledTimes(0); + expect(actions.size).toBe(0); + + await manager.start(); + + expect(create1).toHaveBeenCalledTimes(1); + expect(create2).toHaveBeenCalledTimes(0); + expect(actions.size).toBe(1); + }); + + test('does nothing when no events stored', async () => { + const { manager, actions, uiActions } = setup(); + const create1 = jest.fn(); + const create2 = jest.fn(); + + uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); + uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); + + expect(create1).toHaveBeenCalledTimes(0); + expect(create2).toHaveBeenCalledTimes(0); + expect(actions.size).toBe(0); + + await manager.start(); + + expect(create1).toHaveBeenCalledTimes(0); + expect(create2).toHaveBeenCalledTimes(0); + expect(actions.size).toBe(0); + }); + + test('UI state is empty before manager starts', async () => { + const { manager } = setup([event1]); + + expect(manager.state.get()).toMatchObject({ + events: [], + isFetchingEvents: false, + fetchCount: 0, + }); + }); + + test('loads events into UI state', async () => { + const { manager, uiActions } = setup([event1, event2, event3]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + uiActions.registerActionFactory(actionFactoryDefinition2); + + await manager.start(); + + expect(manager.state.get()).toMatchObject({ + events: [event1, event2, event3], + isFetchingEvents: false, + fetchCount: 1, + }); + }); + + test('sets isFetchingEvents to true while fetching events', async () => { + const { manager, uiActions } = setup([event1, event2, event3]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + uiActions.registerActionFactory(actionFactoryDefinition2); + + const promise = manager.start().catch(() => {}); + + expect(manager.state.get().isFetchingEvents).toBe(true); + + await promise; + + expect(manager.state.get().isFetchingEvents).toBe(false); + }); + + test('throws if storage threw', async () => { + const { manager, storage } = setup([event1]); + + storage.list = async () => { + throw new Error('baz'); + }; + + const [, error] = await of(manager.start()); + + expect(error).toEqual(new Error('baz')); + }); + + test('sets UI state error if error happened during initial fetch', async () => { + const { manager, storage } = setup([event1]); + + storage.list = async () => { + throw new Error('baz'); + }; + + await of(manager.start()); + + expect(manager.state.get().fetchError!.message).toBe('baz'); + }); + }); + + describe('.stop()', () => { + test('removes events from UI actions registry', async () => { + const { manager, actions, uiActions } = setup([event1, event2]); + const create1 = jest.fn(); + const create2 = jest.fn(); + + uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); + uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); + + expect(actions.size).toBe(0); + + await manager.start(); + + expect(actions.size).toBe(2); + + await manager.stop(); + + expect(actions.size).toBe(0); + }); + }); + + describe('.createEvent()', () => { + describe('when storage succeeds', () => { + test('stores new event in storage', async () => { + const { manager, storage, uiActions } = setup([]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + expect(await storage.count()).toBe(0); + + await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); + + expect(await storage.count()).toBe(1); + + const [event] = await storage.list(); + + expect(event).toMatchObject({ + eventId: expect.any(String), + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }, + }); + }); + + test('adds event to UI state', async () => { + const { manager, uiActions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(manager.state.get().events.length).toBe(0); + + await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); + + expect(manager.state.get().events.length).toBe(1); + }); + + test('optimistically adds event to UI state', async () => { + const { manager, uiActions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(manager.state.get().events.length).toBe(0); + + const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e); + + expect(manager.state.get().events.length).toBe(1); + + await promise; + + expect(manager.state.get().events.length).toBe(1); + }); + + test('instantiates event in actions service', async () => { + const { manager, uiActions, actions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(actions.size).toBe(0); + + await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); + + expect(actions.size).toBe(1); + }); + }); + + describe('when storage fails', () => { + test('throws an error', async () => { + const { manager, storage, uiActions } = setup([]); + + storage.create = async () => { + throw new Error('foo'); + }; + + uiActions.registerActionFactory(actionFactoryDefinition1); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + const [, error] = await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); + + expect(error).toEqual(new Error('foo')); + }); + + test('does not add even to UI state', async () => { + const { manager, storage, uiActions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + storage.create = async () => { + throw new Error('foo'); + }; + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); + + expect(manager.state.get().events.length).toBe(0); + }); + + test('optimistically adds event to UI state and then removes it', async () => { + const { manager, storage, uiActions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + storage.create = async () => { + throw new Error('foo'); + }; + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(manager.state.get().events.length).toBe(0); + + const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e); + + expect(manager.state.get().events.length).toBe(1); + + await promise; + + expect(manager.state.get().events.length).toBe(0); + }); + + test('does not instantiate event in actions service', async () => { + const { manager, storage, uiActions, actions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + storage.create = async () => { + throw new Error('foo'); + }; + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(actions.size).toBe(0); + + await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); + + expect(actions.size).toBe(0); + }); + }); + }); + + describe('.updateEvent()', () => { + describe('when storage succeeds', () => { + test('un-registers old event from ui actions service and registers the new one', async () => { + const { manager, actions, uiActions } = setup([event3]); + + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + expect(actions.size).toBe(1); + + const registeredAction1 = actions.values().next().value; + + expect(registeredAction1.getDisplayName()).toBe('Action 3'); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); + + expect(actions.size).toBe(1); + + const registeredAction2 = actions.values().next().value; + + expect(registeredAction2.getDisplayName()).toBe('foo'); + }); + + test('updates event in storage', async () => { + const { manager, storage, uiActions } = setup([event3]); + const storageUpdateSpy = jest.spyOn(storage, 'update'); + + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + expect(storageUpdateSpy).toHaveBeenCalledTimes(0); + + await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); + + expect(storageUpdateSpy).toHaveBeenCalledTimes(1); + expect(storageUpdateSpy.mock.calls[0][0]).toMatchObject({ + eventId: expect.any(String), + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition2.id, + }, + }); + }); + + test('updates event in UI state', async () => { + const { manager, uiActions } = setup([event3]); + + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + expect(manager.state.get().events[0].action.name).toBe('Action 3'); + + await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); + + expect(manager.state.get().events[0].action.name).toBe('foo'); + }); + + test('optimistically updates event in UI state', async () => { + const { manager, uiActions } = setup([event3]); + + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + expect(manager.state.get().events[0].action.name).toBe('Action 3'); + + const promise = manager + .updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']) + .catch(e => e); + + expect(manager.state.get().events[0].action.name).toBe('foo'); + + await promise; + }); + }); + + describe('when storage fails', () => { + test('throws error', async () => { + const { manager, storage, uiActions } = setup([event3]); + + storage.update = () => { + throw new Error('bar'); + }; + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + const [, error] = await of( + manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']) + ); + + expect(error).toEqual(new Error('bar')); + }); + + test('keeps the old action in actions registry', async () => { + const { manager, storage, actions, uiActions } = setup([event3]); + + storage.update = () => { + throw new Error('bar'); + }; + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + expect(actions.size).toBe(1); + + const registeredAction1 = actions.values().next().value; + + expect(registeredAction1.getDisplayName()).toBe('Action 3'); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])); + + expect(actions.size).toBe(1); + + const registeredAction2 = actions.values().next().value; + + expect(registeredAction2.getDisplayName()).toBe('Action 3'); + }); + + test('keeps old event in UI state', async () => { + const { manager, storage, uiActions } = setup([event3]); + + storage.update = () => { + throw new Error('bar'); + }; + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + expect(manager.state.get().events[0].action.name).toBe('Action 3'); + + await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])); + + expect(manager.state.get().events[0].action.name).toBe('Action 3'); + }); + }); + }); + + describe('.deleteEvents()', () => { + describe('when storage succeeds', () => { + test('removes all actions from uiActions service', async () => { + const { manager, actions, uiActions } = setup([event2, event1]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(actions.size).toBe(2); + + await manager.deleteEvents([event1.eventId, event2.eventId]); + + expect(actions.size).toBe(0); + }); + + test('removes all events from storage', async () => { + const { manager, uiActions, storage } = setup([event2, event1]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(await storage.list()).toEqual([event2, event1]); + + await manager.deleteEvents([event1.eventId, event2.eventId]); + + expect(await storage.list()).toEqual([]); + }); + + test('removes all events from UI state', async () => { + const { manager, uiActions } = setup([event2, event1]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(manager.state.get().events).toEqual([event2, event1]); + + await manager.deleteEvents([event1.eventId, event2.eventId]); + + expect(manager.state.get().events).toEqual([]); + }); + }); + }); +}); diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_manager.ts b/src/plugins/ui_actions/public/actions/dynamic_action_manager.ts new file mode 100644 index 0000000000000..97eb5b05fbbc2 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/dynamic_action_manager.ts @@ -0,0 +1,284 @@ +/* + * 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 { v4 as uuidv4 } from 'uuid'; +import { Subscription } from 'rxjs'; +import { ActionStorage, SerializedEvent } from './dynamic_action_storage'; +import { UiActionsService } from '../service'; +import { SerializedAction } from './types'; +import { TriggerContextMapping } from '../types'; +import { ActionDefinition } from './action'; +import { defaultState, transitions, selectors, State } from './dynamic_action_manager_state'; +import { StateContainer, createStateContainer } from '../../../kibana_utils'; + +const compareEvents = ( + a: ReadonlyArray<{ eventId: string }>, + b: ReadonlyArray<{ eventId: string }> +) => { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i].eventId !== b[i].eventId) return false; + return true; +}; + +export type DynamicActionManagerState = State; + +export interface DynamicActionManagerParams { + storage: ActionStorage; + uiActions: Pick< + UiActionsService, + 'registerAction' | 'attachAction' | 'unregisterAction' | 'detachAction' | 'getActionFactory' + >; + isCompatible: (context: C) => Promise; +} + +export class DynamicActionManager { + static idPrefixCounter = 0; + + private readonly idPrefix = `D_ACTION_${DynamicActionManager.idPrefixCounter++}_`; + private stopped: boolean = false; + private reloadSubscription?: Subscription; + + /** + * UI State of the dynamic action manager. + */ + protected readonly ui = createStateContainer(defaultState, transitions, selectors); + + constructor(protected readonly params: DynamicActionManagerParams) {} + + protected getEvent(eventId: string): SerializedEvent { + const oldEvent = this.ui.selectors.getEvent(eventId); + if (!oldEvent) throw new Error(`Could not find event [eventId = ${eventId}].`); + return oldEvent; + } + + /** + * We prefix action IDs with a unique `.idPrefix`, so we can render the + * same dashboard twice on the screen. + */ + protected generateActionId(eventId: string): string { + return this.idPrefix + eventId; + } + + protected reviveAction(event: SerializedEvent) { + const { eventId, triggers, action } = event; + const { uiActions, isCompatible } = this.params; + + const actionId = this.generateActionId(eventId); + const factory = uiActions.getActionFactory(event.action.factoryId); + const actionDefinition: ActionDefinition = { + ...factory.create(action as SerializedAction), + id: actionId, + isCompatible, + }; + + uiActions.registerAction(actionDefinition); + for (const trigger of triggers) uiActions.attachAction(trigger as any, actionId); + } + + protected killAction({ eventId, triggers }: SerializedEvent) { + const { uiActions } = this.params; + const actionId = this.generateActionId(eventId); + + for (const trigger of triggers) uiActions.detachAction(trigger as any, actionId); + uiActions.unregisterAction(actionId); + } + + private syncId = 0; + + /** + * This function is called every time stored events might have changed not by + * us. For example, when in edit mode on dashboard user presses "back" button + * in the browser, then contents of storage changes. + */ + private onSync = () => { + if (this.stopped) return; + + (async () => { + const syncId = ++this.syncId; + const events = await this.params.storage.list(); + + if (this.stopped) return; + if (syncId !== this.syncId) return; + if (compareEvents(events, this.ui.get().events)) return; + + for (const event of this.ui.get().events) this.killAction(event); + for (const event of events) this.reviveAction(event); + this.ui.transitions.finishFetching(events); + })().catch(error => { + /* eslint-disable */ + console.log('Dynamic action manager storage reload failed.'); + console.error(error); + /* eslint-enable */ + }); + }; + + // Public API: --------------------------------------------------------------- + + /** + * Read-only state container of dynamic action manager. Use it to perform all + * *read* operations. + */ + public readonly state: StateContainer = this.ui; + + /** + * 1. Loads all events from @type {DynamicActionStorage} storage. + * 2. Creates actions for each event in `ui_actions` registry. + * 3. Adds events to UI state. + * 4. Does nothing if dynamic action manager was stopped or if event fetching + * is already taking place. + */ + public async start() { + if (this.stopped) return; + if (this.ui.get().isFetchingEvents) return; + + this.ui.transitions.startFetching(); + try { + const events = await this.params.storage.list(); + for (const event of events) this.reviveAction(event); + this.ui.transitions.finishFetching(events); + } catch (error) { + this.ui.transitions.failFetching(error instanceof Error ? error : { message: String(error) }); + throw error; + } + + if (this.params.storage.reload$) { + this.reloadSubscription = this.params.storage.reload$.subscribe(this.onSync); + } + } + + /** + * 1. Removes all events from `ui_actions` registry. + * 2. Puts dynamic action manager is stopped state. + */ + public async stop() { + this.stopped = true; + const events = await this.params.storage.list(); + + for (const event of events) { + this.killAction(event); + } + + if (this.reloadSubscription) { + this.reloadSubscription.unsubscribe(); + } + } + + /** + * Creates a new event. + * + * 1. Stores event in @type {DynamicActionStorage} storage. + * 2. Optimistically adds it to UI state, and rolls back on failure. + * 3. Adds action to `ui_actions` registry. + * + * @param action Dynamic action for which to create an event. + * @param triggers List of triggers to which action should react. + */ + public async createEvent( + action: SerializedAction, + triggers: Array + ) { + const event: SerializedEvent = { + eventId: uuidv4(), + triggers, + action, + }; + + this.ui.transitions.addEvent(event); + try { + await this.params.storage.create(event); + this.reviveAction(event); + } catch (error) { + this.ui.transitions.removeEvent(event.eventId); + throw error; + } + } + + /** + * Updates an existing event. Fails if event with given `eventId` does not + * exit. + * + * 1. Updates the event in @type {DynamicActionStorage} storage. + * 2. Optimistically replaces the old event by the new one in UI state, and + * rolls back on failure. + * 3. Replaces action in `ui_actions` registry with the new event. + * + * + * @param eventId ID of the event to replace. + * @param action New action for which to create the event. + * @param triggers List of triggers to which action should react. + */ + public async updateEvent( + eventId: string, + action: SerializedAction, + triggers: Array + ) { + const event: SerializedEvent = { + eventId, + triggers, + action, + }; + const oldEvent = this.getEvent(eventId); + this.killAction(oldEvent); + + this.reviveAction(event); + this.ui.transitions.replaceEvent(event); + + try { + await this.params.storage.update(event); + } catch (error) { + this.killAction(event); + this.reviveAction(oldEvent); + this.ui.transitions.replaceEvent(oldEvent); + throw error; + } + } + + /** + * Removes existing event. Throws if event does not exist. + * + * 1. Removes the event from @type {DynamicActionStorage} storage. + * 2. Optimistically removes event from UI state, and puts it back on failure. + * 3. Removes associated action from `ui_actions` registry. + * + * @param eventId ID of the event to remove. + */ + public async deleteEvent(eventId: string) { + const event = this.getEvent(eventId); + + this.killAction(event); + this.ui.transitions.removeEvent(eventId); + + try { + await this.params.storage.remove(eventId); + } catch (error) { + this.reviveAction(event); + this.ui.transitions.addEvent(event); + throw error; + } + } + + /** + * Deletes multiple events at once. + * + * @param eventIds List of event IDs. + */ + public async deleteEvents(eventIds: string[]) { + await Promise.all(eventIds.map(this.deleteEvent.bind(this))); + } +} diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts b/src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts new file mode 100644 index 0000000000000..636af076ea39f --- /dev/null +++ b/src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts @@ -0,0 +1,111 @@ +/* + * 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 { SerializedEvent } from './dynamic_action_storage'; + +/** + * This interface represents the state of @type {DynamicActionManager} at any + * point in time. + */ +export interface State { + /** + * Whether dynamic action manager is currently in process of fetching events + * from storage. + */ + readonly isFetchingEvents: boolean; + + /** + * Number of times event fetching has been completed. + */ + readonly fetchCount: number; + + /** + * Error received last time when fetching events. + */ + readonly fetchError?: { + message: string; + }; + + /** + * List of all fetched events. + */ + readonly events: readonly SerializedEvent[]; +} + +export interface Transitions { + startFetching: (state: State) => () => State; + finishFetching: (state: State) => (events: SerializedEvent[]) => State; + failFetching: (state: State) => (error: { message: string }) => State; + addEvent: (state: State) => (event: SerializedEvent) => State; + removeEvent: (state: State) => (eventId: string) => State; + replaceEvent: (state: State) => (event: SerializedEvent) => State; +} + +export interface Selectors { + getEvent: (state: State) => (eventId: string) => SerializedEvent | null; +} + +export const defaultState: State = { + isFetchingEvents: false, + fetchCount: 0, + events: [], +}; + +export const transitions: Transitions = { + startFetching: state => () => ({ ...state, isFetchingEvents: true }), + + finishFetching: state => events => ({ + ...state, + isFetchingEvents: false, + fetchCount: state.fetchCount + 1, + fetchError: undefined, + events, + }), + + failFetching: state => ({ message }) => ({ + ...state, + isFetchingEvents: false, + fetchCount: state.fetchCount + 1, + fetchError: { message }, + }), + + addEvent: state => (event: SerializedEvent) => ({ + ...state, + events: [...state.events, event], + }), + + removeEvent: state => (eventId: string) => ({ + ...state, + events: state.events ? state.events.filter(event => event.eventId !== eventId) : state.events, + }), + + replaceEvent: state => event => { + const index = state.events.findIndex(({ eventId }) => eventId === event.eventId); + if (index === -1) return state; + + return { + ...state, + events: [...state.events.slice(0, index), event, ...state.events.slice(index + 1)], + }; + }, +}; + +export const selectors: Selectors = { + getEvent: state => eventId => state.events.find(event => event.eventId === eventId) || null, +}; diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_storage.ts b/src/plugins/ui_actions/public/actions/dynamic_action_storage.ts new file mode 100644 index 0000000000000..28550a671782e --- /dev/null +++ b/src/plugins/ui_actions/public/actions/dynamic_action_storage.ts @@ -0,0 +1,102 @@ +/* + * 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. + */ + +/* eslint-disable max-classes-per-file */ + +import { Observable, Subject } from 'rxjs'; +import { SerializedAction } from './types'; + +/** + * Serialized representation of event-action pair, used to persist in storage. + */ +export interface SerializedEvent { + eventId: string; + triggers: string[]; + action: SerializedAction; +} + +/** + * This CRUD interface needs to be implemented by dynamic action users if they + * want to persist the dynamic actions. It has a default implementation in + * Embeddables, however one can use the dynamic actions without Embeddables, + * in that case they have to implement this interface. + */ +export interface ActionStorage { + create(event: SerializedEvent): Promise; + update(event: SerializedEvent): Promise; + remove(eventId: string): Promise; + read(eventId: string): Promise; + count(): Promise; + list(): Promise; + + /** + * Triggered every time events changed in storage and should be re-loaded. + */ + readonly reload$?: Observable; +} + +export abstract class AbstractActionStorage implements ActionStorage { + public readonly reload$: Observable & Pick, 'next'> = new Subject(); + + public async count(): Promise { + return (await this.list()).length; + } + + public async read(eventId: string): Promise { + const events = await this.list(); + const event = events.find(ev => ev.eventId === eventId); + if (!event) throw new Error(`Event [eventId = ${eventId}] not found.`); + return event; + } + + abstract create(event: SerializedEvent): Promise; + abstract update(event: SerializedEvent): Promise; + abstract remove(eventId: string): Promise; + abstract list(): Promise; +} + +/** + * This is an in-memory implementation of ActionStorage. It is used in testing, + * but can also be used production code to store events in memory. + */ +export class MemoryActionStorage extends AbstractActionStorage { + constructor(public events: readonly SerializedEvent[] = []) { + super(); + } + + public async list() { + return this.events.map(event => ({ ...event })); + } + + public async create(event: SerializedEvent) { + this.events = [...this.events, { ...event }]; + } + + public async update(event: SerializedEvent) { + const index = this.events.findIndex(({ eventId }) => eventId === event.eventId); + if (index < 0) throw new Error(`Event [eventId = ${event.eventId}] not found`); + this.events = [...this.events.slice(0, index), { ...event }, ...this.events.slice(index + 1)]; + } + + public async remove(eventId: string) { + const index = this.events.findIndex(ev => eventId === ev.eventId); + if (index < 0) throw new Error(`Event [eventId = ${eventId}] not found`); + this.events = [...this.events.slice(0, index), ...this.events.slice(index + 1)]; + } +} diff --git a/src/plugins/ui_actions/public/actions/index.ts b/src/plugins/ui_actions/public/actions/index.ts index 64bfd368e3dfa..0ddba197aced6 100644 --- a/src/plugins/ui_actions/public/actions/index.ts +++ b/src/plugins/ui_actions/public/actions/index.ts @@ -18,5 +18,11 @@ */ export * from './action'; +export * from './action_internal'; +export * from './action_factory_definition'; +export * from './action_factory'; export * from './create_action'; export * from './incompatible_action_error'; +export * from './dynamic_action_storage'; +export * from './dynamic_action_manager'; +export * from './types'; diff --git a/src/plugins/ui_actions/public/actions/types.ts b/src/plugins/ui_actions/public/actions/types.ts new file mode 100644 index 0000000000000..465f091e45ef1 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/types.ts @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export interface SerializedAction { + readonly factoryId: string; + readonly name: string; + readonly config: Config; +} diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index 3dce2c1f4c257..ec58261d9e4f7 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -24,19 +24,25 @@ import { i18n } from '@kbn/i18n'; import { uiToReactComponent } from '../../../kibana_react/public'; import { Action } from '../actions'; +export const defaultTitle = i18n.translate('uiActions.actionPanel.title', { + defaultMessage: 'Options', +}); + /** * Transforms an array of Actions to the shape EuiContextMenuPanel expects. */ -export async function buildContextMenuForActions({ +export async function buildContextMenuForActions({ actions, actionContext, + title = defaultTitle, closeMenu, }: { - actions: Array>; - actionContext: A; + actions: Array>; + actionContext: Context; + title?: string; closeMenu: () => void; }): Promise { - const menuItems = await buildEuiContextMenuPanelItems({ + const menuItems = await buildEuiContextMenuPanelItems({ actions, actionContext, closeMenu, @@ -44,9 +50,7 @@ export async function buildContextMenuForActions({ return { id: 'mainMenu', - title: i18n.translate('uiActions.actionPanel.title', { - defaultMessage: 'Options', - }), + title, items: menuItems, }; } @@ -54,49 +58,41 @@ export async function buildContextMenuForActions({ /** * Transform an array of Actions into the shape needed to build an EUIContextMenu */ -async function buildEuiContextMenuPanelItems({ +async function buildEuiContextMenuPanelItems({ actions, actionContext, closeMenu, }: { - actions: Array>; - actionContext: A; + actions: Array>; + actionContext: Context; closeMenu: () => void; }) { - const items: EuiContextMenuPanelItemDescriptor[] = []; - const promises = actions.map(async action => { + const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length); + const promises = actions.map(async (action, index) => { const isCompatible = await action.isCompatible(actionContext); if (!isCompatible) { return; } - items.push( - convertPanelActionToContextMenuItem({ - action, - actionContext, - closeMenu, - }) - ); + items[index] = convertPanelActionToContextMenuItem({ + action, + actionContext, + closeMenu, + }); }); await Promise.all(promises); - return items; + return items.filter(Boolean); } -/** - * - * @param {ContextMenuAction} action - * @param {Embeddable} embeddable - * @return {EuiContextMenuPanelItemDescriptor} - */ -function convertPanelActionToContextMenuItem({ +function convertPanelActionToContextMenuItem({ action, actionContext, closeMenu, }: { - action: Action; - actionContext: A; + action: Action; + actionContext: Context; closeMenu: () => void; }): EuiContextMenuPanelItemDescriptor { const menuPanelItem: EuiContextMenuPanelItemDescriptor = { @@ -115,8 +111,11 @@ function convertPanelActionToContextMenuItem({ closeMenu(); }; - if (action.getHref && action.getHref(actionContext)) { - menuPanelItem.href = action.getHref(actionContext); + if (action.getHref) { + const href = action.getHref(actionContext); + if (href) { + menuPanelItem.href = action.getHref(actionContext); + } } return menuPanelItem; diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index 49b6bd5e17699..9265d35bad9a9 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -26,8 +26,26 @@ export function plugin(initializerContext: PluginInitializerContext) { export { UiActionsSetup, UiActionsStart } from './plugin'; export { UiActionsServiceParams, UiActionsService } from './service'; -export { Action, createAction, IncompatibleActionError } from './actions'; +export { + Action, + ActionDefinition as UiActionsActionDefinition, + ActionFactoryDefinition as UiActionsActionFactoryDefinition, + ActionInternal as UiActionsActionInternal, + ActionStorage as UiActionsActionStorage, + AbstractActionStorage as UiActionsAbstractActionStorage, + createAction, + DynamicActionManager, + DynamicActionManagerState, + IncompatibleActionError, + SerializedAction as UiActionsSerializedAction, + SerializedEvent as UiActionsSerializedEvent, +} from './actions'; export { buildContextMenuForActions } from './context_menu'; +export { + Presentable as UiActionsPresentable, + Configurable as UiActionsConfigurable, + CollectConfigProps as UiActionsCollectConfigProps, +} from './util'; export { Trigger, TriggerContext, @@ -39,4 +57,4 @@ export { applyFilterTrigger, } from './triggers'; export { TriggerContextMapping, TriggerId, ActionContextMapping, ActionType } from './types'; -export { ActionByType } from './actions'; +export { ActionByType, DynamicActionManager as UiActionsDynamicActionManager } from './actions'; diff --git a/src/plugins/ui_actions/public/mocks.ts b/src/plugins/ui_actions/public/mocks.ts index c1be6b2626525..4de38eb5421e9 100644 --- a/src/plugins/ui_actions/public/mocks.ts +++ b/src/plugins/ui_actions/public/mocks.ts @@ -28,10 +28,13 @@ export type Start = jest.Mocked; const createSetupContract = (): Setup => { const setupContract: Setup = { + addTriggerAction: jest.fn(), attachAction: jest.fn(), detachAction: jest.fn(), registerAction: jest.fn(), + registerActionFactory: jest.fn(), registerTrigger: jest.fn(), + unregisterAction: jest.fn(), }; return setupContract; }; @@ -39,16 +42,21 @@ const createSetupContract = (): Setup => { const createStartContract = (): Start => { const startContract: Start = { attachAction: jest.fn(), - registerAction: jest.fn(), - registerTrigger: jest.fn(), - getAction: jest.fn(), + unregisterAction: jest.fn(), + addTriggerAction: jest.fn(), + clear: jest.fn(), detachAction: jest.fn(), executeTriggerActions: jest.fn(), + fork: jest.fn(), + getAction: jest.fn(), + getActionFactories: jest.fn(), + getActionFactory: jest.fn(), getTrigger: jest.fn(), getTriggerActions: jest.fn((id: TriggerId) => []), getTriggerCompatibleActions: jest.fn(), - clear: jest.fn(), - fork: jest.fn(), + registerAction: jest.fn(), + registerActionFactory: jest.fn(), + registerTrigger: jest.fn(), }; return startContract; diff --git a/src/plugins/ui_actions/public/plugin.ts b/src/plugins/ui_actions/public/plugin.ts index 928e57937a9b5..88a5cb04eac6f 100644 --- a/src/plugins/ui_actions/public/plugin.ts +++ b/src/plugins/ui_actions/public/plugin.ts @@ -23,7 +23,13 @@ import { selectRangeTrigger, valueClickTrigger, applyFilterTrigger } from './tri export type UiActionsSetup = Pick< UiActionsService, - 'attachAction' | 'detachAction' | 'registerAction' | 'registerTrigger' + | 'addTriggerAction' + | 'attachAction' + | 'detachAction' + | 'registerAction' + | 'registerActionFactory' + | 'registerTrigger' + | 'unregisterAction' >; export type UiActionsStart = PublicMethodsOf; diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts index bdf71a25e6dbc..41e2b57d53dd8 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts @@ -18,7 +18,13 @@ */ import { UiActionsService } from './ui_actions_service'; -import { Action, createAction } from '../actions'; +import { + Action, + ActionInternal, + createAction, + ActionFactoryDefinition, + ActionFactory, +} from '../actions'; import { createHelloWorldAction } from '../tests/test_samples'; import { ActionRegistry, TriggerRegistry, TriggerId, ActionType } from '../types'; import { Trigger } from '../triggers'; @@ -102,6 +108,21 @@ describe('UiActionsService', () => { type: 'test' as ActionType, }); }); + + test('return action instance', () => { + const service = new UiActionsService(); + const action = service.registerAction({ + id: 'test', + execute: async () => {}, + getDisplayName: () => 'test', + getIconType: () => '', + isCompatible: async () => true, + type: 'test' as ActionType, + }); + + expect(action).toBeInstanceOf(ActionInternal); + expect(action.id).toBe('test'); + }); }); describe('.getTriggerActions()', () => { @@ -139,13 +160,14 @@ describe('UiActionsService', () => { expect(list0).toHaveLength(0); - service.attachAction(FOO_TRIGGER, action1); + service.addTriggerAction(FOO_TRIGGER, action1); const list1 = service.getTriggerActions(FOO_TRIGGER); expect(list1).toHaveLength(1); - expect(list1).toEqual([action1]); + expect(list1[0]).toBeInstanceOf(ActionInternal); + expect(list1[0].id).toBe(action1.id); - service.attachAction(FOO_TRIGGER, action2); + service.addTriggerAction(FOO_TRIGGER, action2); const list2 = service.getTriggerActions(FOO_TRIGGER); expect(list2).toHaveLength(2); @@ -164,7 +186,7 @@ describe('UiActionsService', () => { service.registerAction(helloWorldAction); expect(actions.size - length).toBe(1); - expect(actions.get(helloWorldAction.id)).toBe(helloWorldAction); + expect(actions.get(helloWorldAction.id)!.id).toBe(helloWorldAction.id); }); test('getTriggerCompatibleActions returns attached actions', async () => { @@ -178,7 +200,7 @@ describe('UiActionsService', () => { title: 'My trigger', }; service.registerTrigger(testTrigger); - service.attachAction(MY_TRIGGER, helloWorldAction); + service.addTriggerAction(MY_TRIGGER, helloWorldAction); const compatibleActions = await service.getTriggerCompatibleActions(MY_TRIGGER, { hi: 'there', @@ -204,7 +226,7 @@ describe('UiActionsService', () => { }; service.registerTrigger(testTrigger); - service.attachAction(testTrigger.id, action); + service.addTriggerAction(testTrigger.id, action); const compatibleActions1 = await service.getTriggerCompatibleActions(testTrigger.id, { accept: true, @@ -288,7 +310,7 @@ describe('UiActionsService', () => { id: FOO_TRIGGER, }); service1.registerAction(testAction1); - service1.attachAction(FOO_TRIGGER, testAction1); + service1.addTriggerAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); @@ -309,14 +331,14 @@ describe('UiActionsService', () => { }); service1.registerAction(testAction1); service1.registerAction(testAction2); - service1.attachAction(FOO_TRIGGER, testAction1); + service1.addTriggerAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); - service2.attachAction(FOO_TRIGGER, testAction2); + service2.addTriggerAction(FOO_TRIGGER, testAction2); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(2); @@ -330,14 +352,14 @@ describe('UiActionsService', () => { }); service1.registerAction(testAction1); service1.registerAction(testAction2); - service1.attachAction(FOO_TRIGGER, testAction1); + service1.addTriggerAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); - service1.attachAction(FOO_TRIGGER, testAction2); + service1.addTriggerAction(FOO_TRIGGER, testAction2); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(2); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); @@ -392,7 +414,7 @@ describe('UiActionsService', () => { } as any; service.registerTrigger(trigger); - service.attachAction(MY_TRIGGER, action); + service.addTriggerAction(MY_TRIGGER, action); const actions = service.getTriggerActions(trigger.id); @@ -400,7 +422,7 @@ describe('UiActionsService', () => { expect(actions[0].id).toBe(ACTION_HELLO_WORLD); }); - test('can detach an action to a trigger', () => { + test('can detach an action from a trigger', () => { const service = new UiActionsService(); const trigger: Trigger = { @@ -413,7 +435,7 @@ describe('UiActionsService', () => { service.registerTrigger(trigger); service.registerAction(action); - service.attachAction(trigger.id, action); + service.addTriggerAction(trigger.id, action); service.detachAction(trigger.id, action.id); const actions2 = service.getTriggerActions(trigger.id); @@ -445,7 +467,7 @@ describe('UiActionsService', () => { } as any; service.registerAction(action); - expect(() => service.attachAction('i do not exist' as TriggerId, action)).toThrowError( + expect(() => service.addTriggerAction('i do not exist' as TriggerId, action)).toThrowError( 'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = ACTION_HELLO_WORLD].' ); }); @@ -475,4 +497,64 @@ describe('UiActionsService', () => { ); }); }); + + describe('action factories', () => { + const factoryDefinition1: ActionFactoryDefinition = { + id: 'test-factory-1', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: () => true, + create: () => ({} as any), + }; + const factoryDefinition2: ActionFactoryDefinition = { + id: 'test-factory-2', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: () => true, + create: () => ({} as any), + }; + + test('.getActionFactories() returns empty array if no action factories registered', () => { + const service = new UiActionsService(); + + const factories = service.getActionFactories(); + + expect(factories).toEqual([]); + }); + + test('can register and retrieve an action factory', () => { + const service = new UiActionsService(); + + service.registerActionFactory(factoryDefinition1); + + const factory = service.getActionFactory(factoryDefinition1.id); + + expect(factory).toBeInstanceOf(ActionFactory); + expect(factory.id).toBe(factoryDefinition1.id); + }); + + test('can retrieve all action factories', () => { + const service = new UiActionsService(); + + service.registerActionFactory(factoryDefinition1); + service.registerActionFactory(factoryDefinition2); + + const factories = service.getActionFactories(); + const factoriesSorted = [...factories].sort((f1, f2) => (f1.id > f2.id ? 1 : -1)); + + expect(factoriesSorted.length).toBe(2); + expect(factoriesSorted[0].id).toBe(factoryDefinition1.id); + expect(factoriesSorted[1].id).toBe(factoryDefinition2.id); + }); + + test('throws when retrieving action factory that does not exist', () => { + const service = new UiActionsService(); + + service.registerActionFactory(factoryDefinition1); + + expect(() => service.getActionFactory('UNKNOWN_ID')).toThrowError( + 'Action factory [actionFactoryId = UNKNOWN_ID] does not exist.' + ); + }); + }); }); diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.ts b/src/plugins/ui_actions/public/service/ui_actions_service.ts index f7718e63773f5..8bd3bb34fbbd8 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.ts @@ -24,8 +24,17 @@ import { TriggerId, TriggerContextMapping, ActionType, + ActionFactoryRegistry, } from '../types'; -import { Action, ActionByType } from '../actions'; +import { + ActionInternal, + Action, + ActionByType, + ActionFactory, + ActionDefinition, + ActionFactoryDefinition, + ActionContext, +} from '../actions'; import { Trigger, TriggerContext } from '../triggers/trigger'; import { TriggerInternal } from '../triggers/trigger_internal'; import { TriggerContract } from '../triggers/trigger_contract'; @@ -38,21 +47,25 @@ export interface UiActionsServiceParams { * A 1-to-N mapping from `Trigger` to zero or more `Action`. */ readonly triggerToActions?: TriggerToActionsRegistry; + readonly actionFactories?: ActionFactoryRegistry; } export class UiActionsService { protected readonly triggers: TriggerRegistry; protected readonly actions: ActionRegistry; protected readonly triggerToActions: TriggerToActionsRegistry; + protected readonly actionFactories: ActionFactoryRegistry; constructor({ triggers = new Map(), actions = new Map(), triggerToActions = new Map(), + actionFactories = new Map(), }: UiActionsServiceParams = {}) { this.triggers = triggers; this.actions = actions; this.triggerToActions = triggerToActions; + this.actionFactories = actionFactories; } public readonly registerTrigger = (trigger: Trigger) => { @@ -76,49 +89,44 @@ export class UiActionsService { return trigger.contract; }; - public readonly registerAction = (action: ActionByType) => { - if (this.actions.has(action.id)) { - throw new Error(`Action [action.id = ${action.id}] already registered.`); + public readonly registerAction = ( + definition: A + ): ActionInternal => { + if (this.actions.has(definition.id)) { + throw new Error(`Action [action.id = ${definition.id}] already registered.`); } + const action = new ActionInternal(definition); + this.actions.set(action.id, action); + + return action; }; - public readonly getAction = (id: string): ActionByType => { - if (!this.actions.has(id)) { - throw new Error(`Action [action.id = ${id}] not registered.`); + public readonly unregisterAction = (actionId: string): void => { + if (!this.actions.has(actionId)) { + throw new Error(`Action [action.id = ${actionId}] is not registered.`); } - return this.actions.get(id) as ActionByType; + this.actions.delete(actionId); }; - public readonly attachAction = ( - triggerId: TType, - // The action can accept partial or no context, but if it needs context not provided - // by this type of trigger, typescript will complain. yay! - action: ActionByType & Action + public readonly attachAction = ( + triggerId: TriggerId, + actionId: string ): void => { - if (!this.actions.has(action.id)) { - this.registerAction(action); - } else { - const registeredAction = this.actions.get(action.id); - if (registeredAction !== action) { - throw new Error(`A different action instance with this id is already registered.`); - } - } - const trigger = this.triggers.get(triggerId); if (!trigger) { throw new Error( - `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${action.id}].` + `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${actionId}].` ); } const actionIds = this.triggerToActions.get(triggerId); - if (!actionIds!.find(id => id === action.id)) { - this.triggerToActions.set(triggerId, [...actionIds!, action.id]); + if (!actionIds!.find(id => id === actionId)) { + this.triggerToActions.set(triggerId, [...actionIds!, actionId]); } }; @@ -139,6 +147,26 @@ export class UiActionsService { ); }; + public readonly addTriggerAction = ( + triggerId: TType, + // The action can accept partial or no context, but if it needs context not provided + // by this type of trigger, typescript will complain. yay! + action: ActionByType & Action + ): void => { + if (!this.actions.has(action.id)) this.registerAction(action); + this.attachAction(triggerId, action.id); + }; + + public readonly getAction = ( + id: string + ): Action> => { + if (!this.actions.has(id)) { + throw new Error(`Action [action.id = ${id}] not registered.`); + } + + return this.actions.get(id) as ActionInternal; + }; + public readonly getTriggerActions = ( triggerId: T ): Array> => { @@ -147,9 +175,9 @@ export class UiActionsService { const actionIds = this.triggerToActions.get(triggerId); - const actions = actionIds!.map(actionId => this.actions.get(actionId)).filter(Boolean) as Array< - Action - >; + const actions = actionIds! + .map(actionId => this.actions.get(actionId) as ActionInternal) + .filter(Boolean); return actions as Array>>; }; @@ -187,6 +215,7 @@ export class UiActionsService { this.actions.clear(); this.triggers.clear(); this.triggerToActions.clear(); + this.actionFactories.clear(); }; /** @@ -206,4 +235,41 @@ export class UiActionsService { return new UiActionsService({ triggers, actions, triggerToActions }); }; + + /** + * Register an action factory. Action factories are used to configure and + * serialize/deserialize dynamic actions. + */ + public readonly registerActionFactory = < + Config extends object = object, + FactoryContext extends object = object, + ActionContext extends object = object + >( + definition: ActionFactoryDefinition + ) => { + if (this.actionFactories.has(definition.id)) { + throw new Error(`ActionFactory [actionFactory.id = ${definition.id}] already registered.`); + } + + const actionFactory = new ActionFactory(definition); + + this.actionFactories.set(actionFactory.id, actionFactory as ActionFactory); + }; + + public readonly getActionFactory = (actionFactoryId: string): ActionFactory => { + const actionFactory = this.actionFactories.get(actionFactoryId); + + if (!actionFactory) { + throw new Error(`Action factory [actionFactoryId = ${actionFactoryId}] does not exist.`); + } + + return actionFactory; + }; + + /** + * Returns an array of all action factories. + */ + public readonly getActionFactories = (): ActionFactory[] => { + return [...this.actionFactories.values()]; + }; } diff --git a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts index 5b427f918c173..ade21ee4b7d91 100644 --- a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts @@ -69,7 +69,7 @@ test('executes a single action mapped to a trigger', async () => { const action = createTestAction('test1', () => true); setup.registerTrigger(trigger); - setup.attachAction(trigger.id, action); + setup.addTriggerAction(trigger.id, action); const context = {}; const start = doStart(); @@ -109,7 +109,7 @@ test('does not execute an incompatible action', async () => { ); setup.registerTrigger(trigger); - setup.attachAction(trigger.id, action); + setup.addTriggerAction(trigger.id, action); const start = doStart(); const context = { @@ -130,8 +130,8 @@ test('shows a context menu when more than one action is mapped to a trigger', as const action2 = createTestAction('test2', () => true); setup.registerTrigger(trigger); - setup.attachAction(trigger.id, action1); - setup.attachAction(trigger.id, action2); + setup.addTriggerAction(trigger.id, action1); + setup.addTriggerAction(trigger.id, action2); expect(openContextMenu).toHaveBeenCalledTimes(0); @@ -155,7 +155,7 @@ test('passes whole action context to isCompatible()', async () => { }); setup.registerTrigger(trigger); - setup.attachAction(trigger.id, action); + setup.addTriggerAction(trigger.id, action); const start = doStart(); diff --git a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts index f5a6a96fb41a4..55ccac42ff255 100644 --- a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Action } from '../actions'; +import { ActionInternal, Action } from '../actions'; import { uiActionsPluginMock } from '../mocks'; import { TriggerId, ActionType } from '../types'; @@ -47,13 +47,14 @@ test('returns actions set on trigger', () => { expect(list0).toHaveLength(0); - setup.attachAction('trigger' as TriggerId, action1); + setup.addTriggerAction('trigger' as TriggerId, action1); const list1 = start.getTriggerActions('trigger' as TriggerId); expect(list1).toHaveLength(1); - expect(list1).toEqual([action1]); + expect(list1[0]).toBeInstanceOf(ActionInternal); + expect(list1[0].id).toBe(action1.id); - setup.attachAction('trigger' as TriggerId, action2); + setup.addTriggerAction('trigger' as TriggerId, action2); const list2 = start.getTriggerActions('trigger' as TriggerId); expect(list2).toHaveLength(2); diff --git a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts index c5e68e5d5ca5a..21dd17ed82e3f 100644 --- a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts @@ -37,7 +37,7 @@ beforeEach(() => { id: 'trigger' as TriggerId, title: 'trigger', }); - uiActions.setup.attachAction('trigger' as TriggerId, action); + uiActions.setup.addTriggerAction('trigger' as TriggerId, action); }); test('can register action', async () => { @@ -58,7 +58,7 @@ test('getTriggerCompatibleActions returns attached actions', async () => { title: 'My trigger', }; setup.registerTrigger(testTrigger); - setup.attachAction('MY-TRIGGER' as TriggerId, helloWorldAction); + setup.addTriggerAction('MY-TRIGGER' as TriggerId, helloWorldAction); const start = doStart(); const actions = await start.getTriggerCompatibleActions('MY-TRIGGER' as TriggerId, {}); @@ -84,7 +84,7 @@ test('filters out actions not applicable based on the context', async () => { setup.registerTrigger(testTrigger); setup.registerAction(action1); - setup.attachAction(testTrigger.id, action1); + setup.addTriggerAction(testTrigger.id, action1); const start = doStart(); let actions = await start.getTriggerCompatibleActions(testTrigger.id, { accept: true }); diff --git a/src/plugins/ui_actions/public/tests/test_samples/index.ts b/src/plugins/ui_actions/public/tests/test_samples/index.ts index 7d63b1b6d5669..dfa71cec89595 100644 --- a/src/plugins/ui_actions/public/tests/test_samples/index.ts +++ b/src/plugins/ui_actions/public/tests/test_samples/index.ts @@ -16,4 +16,5 @@ * specific language governing permissions and limitations * under the License. */ + export { createHelloWorldAction } from './hello_world_action'; diff --git a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts index c638db0ce9dab..9758508dc3dac 100644 --- a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts @@ -22,6 +22,6 @@ import { Trigger } from '.'; export const SELECT_RANGE_TRIGGER = 'SELECT_RANGE_TRIGGER'; export const selectRangeTrigger: Trigger<'SELECT_RANGE_TRIGGER'> = { id: SELECT_RANGE_TRIGGER, - title: 'Select range', + title: '', description: 'Applies a range filter', }; diff --git a/src/plugins/ui_actions/public/triggers/trigger_internal.ts b/src/plugins/ui_actions/public/triggers/trigger_internal.ts index 5b670df354f78..9885ed3abe93b 100644 --- a/src/plugins/ui_actions/public/triggers/trigger_internal.ts +++ b/src/plugins/ui_actions/public/triggers/trigger_internal.ts @@ -72,6 +72,7 @@ export class TriggerInternal { const panel = await buildContextMenuForActions({ actions, actionContext: context, + title: this.trigger.title, closeMenu: () => session.close(), }); const session = openContextMenu([panel]); diff --git a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts index ad32bdc1b564e..2671584d105c8 100644 --- a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts @@ -22,6 +22,6 @@ import { Trigger } from '.'; export const VALUE_CLICK_TRIGGER = 'VALUE_CLICK_TRIGGER'; export const valueClickTrigger: Trigger<'VALUE_CLICK_TRIGGER'> = { id: VALUE_CLICK_TRIGGER, - title: 'Value clicked', + title: '', description: 'Value was clicked', }; diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index c7e6d61e15f31..2cb4a8f26a879 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -17,15 +17,17 @@ * under the License. */ -import { ActionByType } from './actions/action'; +import { ActionInternal } from './actions/action_internal'; import { TriggerInternal } from './triggers/trigger_internal'; +import { ActionFactory } from './actions'; import { EmbeddableVisTriggerContext, IEmbeddable } from '../../embeddable/public'; import { Filter } from '../../data/public'; import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers'; export type TriggerRegistry = Map>; -export type ActionRegistry = Map>; +export type ActionRegistry = Map; export type TriggerToActionsRegistry = Map; +export type ActionFactoryRegistry = Map; const DEFAULT_TRIGGER = ''; diff --git a/src/plugins/ui_actions/public/util/configurable.ts b/src/plugins/ui_actions/public/util/configurable.ts new file mode 100644 index 0000000000000..d3a527a2183b1 --- /dev/null +++ b/src/plugins/ui_actions/public/util/configurable.ts @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { UiComponent } from 'src/plugins/kibana_utils/common'; + +/** + * Represents something that can be configured by user using UI. + */ +export interface Configurable { + /** + * Create default config for this item, used when item is created for the first time. + */ + readonly createConfig: () => Config; + + /** + * Is this config valid. Used to validate user's input before saving. + */ + readonly isConfigValid: (config: Config) => boolean; + + /** + * `UiComponent` to be rendered when collecting configuration for this item. + */ + readonly CollectConfig: UiComponent>; +} + +/** + * Props provided to `CollectConfig` component on every re-render. + */ +export interface CollectConfigProps { + /** + * Current (latest) config of the item. + */ + config: Config; + + /** + * Callback called when user updates the config in UI. + */ + onConfig: (config: Config) => void; + + /** + * Context information about where component is being rendered. + */ + context: Context; +} diff --git a/src/plugins/ui_actions/public/util/index.ts b/src/plugins/ui_actions/public/util/index.ts new file mode 100644 index 0000000000000..53c6109cac4ca --- /dev/null +++ b/src/plugins/ui_actions/public/util/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './presentable'; +export * from './configurable'; diff --git a/src/plugins/ui_actions/public/actions/action_definition.ts b/src/plugins/ui_actions/public/util/presentable.ts similarity index 50% rename from src/plugins/ui_actions/public/actions/action_definition.ts rename to src/plugins/ui_actions/public/util/presentable.ts index c590cf8f34ee0..945fd2065ce78 100644 --- a/src/plugins/ui_actions/public/actions/action_definition.ts +++ b/src/plugins/ui_actions/public/util/presentable.ts @@ -18,55 +18,46 @@ */ import { UiComponent } from 'src/plugins/kibana_utils/common'; -import { ActionType, ActionContextMapping } from '../types'; -export interface ActionDefinition { +/** + * Represents something that can be displayed to user in UI. + */ +export interface Presentable { /** - * Determined the order when there is more than one action matched to a trigger. - * Higher numbers are displayed first. + * ID that uniquely identifies this object. */ - order?: number; + readonly id: string; /** - * A unique identifier for this action instance. + * Determines the display order in relation to other items. Higher numbers are + * displayed first. */ - id?: string; + readonly order: number; /** - * The action type is what determines the context shape. + * `UiComponent` to render when displaying this entity as a context menu item. + * If not provided, `getDisplayName` will be used instead. */ - readonly type: T; + readonly MenuItem?: UiComponent<{ context: Context }>; /** * Optional EUI icon type that can be displayed along with the title. */ - getIconType?(context: ActionContextMapping[T]): string; + getIconType(context: Context): string | undefined; /** * Returns a title to be displayed to the user. - * @param context - */ - getDisplayName?(context: ActionContextMapping[T]): string; - - /** - * `UiComponent` to render when displaying this action as a context menu item. - * If not provided, `getDisplayName` will be used instead. - */ - MenuItem?: UiComponent<{ context: ActionContextMapping[T] }>; - - /** - * Returns a promise that resolves to true if this action is compatible given the context, - * otherwise resolves to false. */ - isCompatible?(context: ActionContextMapping[T]): Promise; + getDisplayName(context: Context): string; /** - * If this returns something truthy, this is used in addition to the `execute` method when clicked. + * This method should return a link if this item can be clicked on. */ - getHref?(context: ActionContextMapping[T]): string | undefined; + getHref?(context: Context): string | undefined; /** - * Executes the action. + * Returns a promise that resolves to true if this item is compatible given + * the context and should be displayed to user, otherwise resolves to false. */ - execute(context: ActionContextMapping[T]): Promise; + isCompatible(context: Context): Promise; } diff --git a/src/plugins/ui_actions/scripts/storybook.js b/src/plugins/ui_actions/scripts/storybook.js new file mode 100644 index 0000000000000..cb2eda610170d --- /dev/null +++ b/src/plugins/ui_actions/scripts/storybook.js @@ -0,0 +1,26 @@ +/* + * 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 { join } from 'path'; + +// eslint-disable-next-line +require('@kbn/storybook').runStorybookCli({ + name: 'ui_actions', + storyGlobs: [join(__dirname, '..', 'public', 'components', '**', '*.story.tsx')], +}); diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx index 18ceec652392d..8ddb2e1a4803b 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx @@ -70,11 +70,10 @@ export class EmbeddableExplorerPublicPlugin const sayHelloAction = new SayHelloAction(alert); const sendMessageAction = createSendMessageAction(core.overlays); - plugins.uiActions.registerAction(helloWorldAction); plugins.uiActions.registerAction(sayHelloAction); plugins.uiActions.registerAction(sendMessageAction); - plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, helloWorldAction); + plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, helloWorldAction); plugins.__LEGACY.onRenderComplete(() => { const root = document.getElementById(REACT_ROOT_ID); diff --git a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx index 8395fddece2a4..7c7cc689d05e5 100644 --- a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx +++ b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx @@ -62,5 +62,4 @@ function createSamplePanelAction() { } const action = createSamplePanelAction(); -npSetup.plugins.uiActions.registerAction(action); -npSetup.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); +npSetup.plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); diff --git a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts index 4b09be4db8a60..e034fbe320608 100644 --- a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts +++ b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts @@ -33,5 +33,4 @@ export const createSamplePanelLink = (): Action => }); const action = createSamplePanelLink(); -npStart.plugins.uiActions.registerAction(action); -npStart.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); +npStart.plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 2a28e349ace99..784b5a5a42ace 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -9,6 +9,7 @@ "xpack.beatsManagement": "legacy/plugins/beats_management", "xpack.canvas": "legacy/plugins/canvas", "xpack.crossClusterReplication": "legacy/plugins/cross_cluster_replication", + "xpack.dashboard": "plugins/dashboard_enhanced", "xpack.dashboardMode": "legacy/plugins/dashboard_mode", "xpack.data": "plugins/data_enhanced", "xpack.drilldowns": "plugins/drilldowns", diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss index 2ba6f9baca90d..87ec3f8fc7ec1 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss @@ -1,8 +1,3 @@ -.auaActionWizard__selectedActionFactoryContainer { - background-color: $euiColorLightestShade; - padding: $euiSize; -} - .auaActionWizard__actionFactoryItem { .euiKeyPadMenuItem__label { height: #{$euiSizeXL}; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx index 62f16890cade2..9c73f07289dc9 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx @@ -6,28 +6,26 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; -import { dashboardDrilldownActionFactory, Demo, urlDrilldownActionFactory } from './test_data'; +import { Demo, dashboardFactory, urlFactory } from './test_data'; storiesOf('components/ActionWizard', module) - .add('default', () => ( - - )) + .add('default', () => ) .add('Only one factory is available', () => ( // to make sure layout doesn't break - + )) .add('Long list of action factories', () => ( // to make sure layout doesn't break )); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx index aea47be693b8f..cc56714fcb2f8 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx @@ -8,21 +8,14 @@ import React from 'react'; import { cleanup, fireEvent, render } from '@testing-library/react/pure'; import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global import { TEST_SUBJ_ACTION_FACTORY_ITEM, TEST_SUBJ_SELECTED_ACTION_FACTORY } from './action_wizard'; -import { - dashboardDrilldownActionFactory, - dashboards, - Demo, - urlDrilldownActionFactory, -} from './test_data'; +import { dashboardFactory, dashboards, Demo, urlFactory } from './test_data'; // TODO: afterEach is not available for it globally during setup // https://github.com/elastic/kibana/issues/59469 afterEach(cleanup); test('Pick and configure action', () => { - const screen = render( - - ); + const screen = render(); // check that all factories are displayed to pick expect(screen.getAllByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).toHaveLength(2); @@ -47,7 +40,7 @@ test('Pick and configure action', () => { }); test('If only one actions factory is available then actionFactory selection is emitted without user input', () => { - const screen = render(); + const screen = render(); // check that no factories are displayed to pick from expect(screen.queryByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).not.toBeInTheDocument(); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx index 41ef863c00e44..846f6d41eb30d 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx @@ -16,40 +16,23 @@ import { } from '@elastic/eui'; import { txtChangeButton } from './i18n'; import './action_wizard.scss'; +import { ActionFactory } from '../../services'; -// TODO: this interface is temporary for just moving forward with the component -// and it will be imported from the ../ui_actions when implemented properly -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type ActionBaseConfig = {}; -export interface ActionFactory { - type: string; // TODO: type should be tied to Action and ActionByType - displayName: string; - iconType?: string; - wizard: React.FC>; - createConfig: () => Config; - isValid: (config: Config) => boolean; -} - -export interface ActionFactoryWizardProps { - config?: Config; - - /** - * Callback called when user updates the config in UI. - */ - onConfig: (config: Config) => void; -} +type ActionBaseConfig = object; +type ActionFactoryBaseContext = object; export interface ActionWizardProps { /** * List of available action factories */ - actionFactories: Array>; // any here to be able to pass array of ActionFactory with different configs + actionFactories: ActionFactory[]; /** * Currently selected action factory * undefined - is allowed and means that non is selected */ currentActionFactory?: ActionFactory; + /** * Action factory selected changed * null - means user click "change" and removed action factory selection @@ -65,6 +48,11 @@ export interface ActionWizardProps { * config changed */ onConfigChange: (config: ActionBaseConfig) => void; + + /** + * Context will be passed into ActionFactory's methods + */ + context: ActionFactoryBaseContext; } export const ActionWizard: React.FC = ({ @@ -73,6 +61,7 @@ export const ActionWizard: React.FC = ({ onActionFactoryChange, onConfigChange, config, + context, }) => { // auto pick action factory if there is only 1 available if (!currentActionFactory && actionFactories.length === 1) { @@ -87,6 +76,7 @@ export const ActionWizard: React.FC = ({ onDeselect={() => { onActionFactoryChange(null); }} + context={context} config={config} onConfigChange={newConfig => { onConfigChange(newConfig); @@ -97,6 +87,7 @@ export const ActionWizard: React.FC = ({ return ( { onActionFactoryChange(actionFactory); @@ -105,10 +96,11 @@ export const ActionWizard: React.FC = ({ ); }; -interface SelectedActionFactoryProps { - actionFactory: ActionFactory; - config: Config; - onConfigChange: (config: Config) => void; +interface SelectedActionFactoryProps { + actionFactory: ActionFactory; + config: ActionBaseConfig; + context: ActionFactoryBaseContext; + onConfigChange: (config: ActionBaseConfig) => void; showDeselect: boolean; onDeselect: () => void; } @@ -121,28 +113,28 @@ const SelectedActionFactory: React.FC = ({ showDeselect, onConfigChange, config, + context, }) => { return (
- {actionFactory.iconType && ( + {actionFactory.getIconType(context) && ( - + )} -

{actionFactory.displayName}

+

{actionFactory.getDisplayName(context)}

{showDeselect && ( - onDeselect()}> + onDeselect()}> {txtChangeButton} @@ -151,10 +143,11 @@ const SelectedActionFactory: React.FC = ({
- {actionFactory.wizard({ - config, - onConfig: onConfigChange, - })} +
); @@ -162,6 +155,7 @@ const SelectedActionFactory: React.FC = ({ interface ActionFactorySelectorProps { actionFactories: ActionFactory[]; + context: ActionFactoryBaseContext; onActionFactorySelected: (actionFactory: ActionFactory) => void; } @@ -170,6 +164,7 @@ export const TEST_SUBJ_ACTION_FACTORY_ITEM = 'action-factory-item'; const ActionFactorySelector: React.FC = ({ actionFactories, onActionFactorySelected, + context, }) => { if (actionFactories.length === 0) { // this is not user facing, as it would be impossible to get into this state @@ -178,19 +173,23 @@ const ActionFactorySelector: React.FC = ({ } return ( - - {actionFactories.map(actionFactory => ( - onActionFactorySelected(actionFactory)} - > - {actionFactory.iconType && } - - ))} + + {[...actionFactories] + .sort((f1, f2) => f1.order - f2.order) + .map(actionFactory => ( + + onActionFactorySelected(actionFactory)} + > + {actionFactory.getIconType(context) && ( + + )} + + + ))} ); }; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts index 641f25176264a..a315184bf68ef 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts @@ -9,6 +9,6 @@ import { i18n } from '@kbn/i18n'; export const txtChangeButton = i18n.translate( 'xpack.advancedUiActions.components.actionWizard.changeButton', { - defaultMessage: 'change', + defaultMessage: 'Change', } ); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts index ed224248ec4cd..a189afbf956ee 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ActionFactory, ActionWizard } from './action_wizard'; +export { ActionWizard } from './action_wizard'; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx index 8ecdde681069e..167cb130fdb4a 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx @@ -6,124 +6,161 @@ import React, { useState } from 'react'; import { EuiFieldText, EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui'; -import { ActionFactory, ActionBaseConfig, ActionWizard } from './action_wizard'; +import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; +import { ActionWizard } from './action_wizard'; +import { ActionFactoryDefinition, ActionFactory } from '../../services'; +import { CollectConfigProps } from '../../util'; + +type ActionBaseConfig = object; export const dashboards = [ { id: 'dashboard1', title: 'Dashboard 1' }, { id: 'dashboard2', title: 'Dashboard 2' }, ]; -export const dashboardDrilldownActionFactory: ActionFactory<{ +interface DashboardDrilldownConfig { dashboardId?: string; - useCurrentDashboardFilters: boolean; - useCurrentDashboardDataRange: boolean; -}> = { - type: 'Dashboard', - displayName: 'Go to Dashboard', - iconType: 'dashboardApp', + useCurrentFilters: boolean; + useCurrentDateRange: boolean; +} + +function DashboardDrilldownCollectConfig(props: CollectConfigProps) { + const config = props.config ?? { + dashboardId: undefined, + useCurrentFilters: true, + useCurrentDateRange: true, + }; + return ( + <> + + ({ value: id, text: title }))} + value={config.dashboardId} + onChange={e => { + props.onConfig({ ...config, dashboardId: e.target.value }); + }} + /> + + + + props.onConfig({ + ...config, + useCurrentFilters: !config.useCurrentFilters, + }) + } + /> + + + + props.onConfig({ + ...config, + useCurrentDateRange: !config.useCurrentDateRange, + }) + } + /> + + + ); +} + +export const dashboardDrilldownActionFactory: ActionFactoryDefinition< + DashboardDrilldownConfig, + any, + any +> = { + id: 'Dashboard', + getDisplayName: () => 'Go to Dashboard', + getIconType: () => 'dashboardApp', createConfig: () => { return { dashboardId: undefined, - useCurrentDashboardDataRange: true, - useCurrentDashboardFilters: true, + useCurrentFilters: true, + useCurrentDateRange: true, }; }, - isValid: config => { + isConfigValid: (config: DashboardDrilldownConfig): config is DashboardDrilldownConfig => { if (!config.dashboardId) return false; return true; }, - wizard: props => { - const config = props.config ?? { - dashboardId: undefined, - useCurrentDashboardDataRange: true, - useCurrentDashboardFilters: true, - }; - return ( - <> - - ({ value: id, text: title }))} - value={config.dashboardId} - onChange={e => { - props.onConfig({ ...config, dashboardId: e.target.value }); - }} - /> - - - - props.onConfig({ - ...config, - useCurrentDashboardFilters: !config.useCurrentDashboardFilters, - }) - } - /> - - - - props.onConfig({ - ...config, - useCurrentDashboardDataRange: !config.useCurrentDashboardDataRange, - }) - } - /> - - - ); + CollectConfig: reactToUiComponent(DashboardDrilldownCollectConfig), + + isCompatible(context?: object): Promise { + return Promise.resolve(true); }, + order: 0, + create: () => ({ + id: 'test', + execute: async () => alert('Navigate to dashboard!'), + }), }; -export const urlDrilldownActionFactory: ActionFactory<{ url: string; openInNewTab: boolean }> = { - type: 'Url', - displayName: 'Go to URL', - iconType: 'link', +export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory); + +interface UrlDrilldownConfig { + url: string; + openInNewTab: boolean; +} +function UrlDrilldownCollectConfig(props: CollectConfigProps) { + const config = props.config ?? { + url: '', + openInNewTab: false, + }; + return ( + <> + + props.onConfig({ ...config, url: event.target.value })} + /> + + + props.onConfig({ ...config, openInNewTab: !config.openInNewTab })} + /> + + + ); +} +export const urlDrilldownActionFactory: ActionFactoryDefinition = { + id: 'Url', + getDisplayName: () => 'Go to URL', + getIconType: () => 'link', createConfig: () => { return { url: '', openInNewTab: false, }; }, - isValid: config => { + isConfigValid: (config: UrlDrilldownConfig): config is UrlDrilldownConfig => { if (!config.url) return false; return true; }, - wizard: props => { - const config = props.config ?? { - url: '', - openInNewTab: false, - }; - return ( - <> - - props.onConfig({ ...config, url: event.target.value })} - /> - - - props.onConfig({ ...config, openInNewTab: !config.openInNewTab })} - /> - - - ); + CollectConfig: reactToUiComponent(UrlDrilldownCollectConfig), + + order: 10, + isCompatible(context?: object): Promise { + return Promise.resolve(true); }, + create: () => null as any, }; +export const urlFactory = new ActionFactory(urlDrilldownActionFactory); + export function Demo({ actionFactories }: { actionFactories: Array> }) { const [state, setState] = useState<{ currentActionFactory?: ActionFactory; @@ -157,14 +194,15 @@ export function Demo({ actionFactories }: { actionFactories: Array

-
Action Factory Type: {state.currentActionFactory?.type}
+
Action Factory Id: {state.currentActionFactory?.id}
Action Factory Config: {JSON.stringify(state.config)}
Is config valid:{' '} - {JSON.stringify(state.currentActionFactory?.isValid(state.config!) ?? false)} + {JSON.stringify(state.currentActionFactory?.isConfigValid(state.config!) ?? false)}
); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx b/x-pack/plugins/advanced_ui_actions/public/components/index.ts similarity index 87% rename from x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx rename to x-pack/plugins/advanced_ui_actions/public/components/index.ts index 3be289fe6d46e..236b1a6ec4611 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './drilldown_picker'; +export * from './action_wizard'; diff --git a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx index 325a5ddc10179..c0cd8d5540db2 100644 --- a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx @@ -44,7 +44,7 @@ export class CustomTimeRangeAction implements ActionByType { + implements Plugin { constructor(initializerContext: PluginInitializerContext) {} - public setup(core: CoreSetup, { uiActions }: SetupDependencies): Setup {} + public setup(core: CoreSetup, { uiActions }: SetupDependencies): SetupContract { + return { + ...uiActions, + }; + } - public start(core: CoreStart, { uiActions }: StartDependencies): Start { + public start(core: CoreStart, { uiActions }: StartDependencies): StartContract { const dateFormat = core.uiSettings.get('dateFormat') as string; const commonlyUsedRanges = core.uiSettings.get('timepicker:quickRanges') as CommonlyUsedRange[]; const { openModal } = createReactOverlays(core); @@ -66,16 +72,18 @@ export class AdvancedUiActionsPublicPlugin dateFormat, commonlyUsedRanges, }); - uiActions.registerAction(timeRangeAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, timeRangeAction); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, timeRangeAction); const timeRangeBadge = new CustomTimeRangeBadge({ openModal, dateFormat, commonlyUsedRanges, }); - uiActions.registerAction(timeRangeBadge); - uiActions.attachAction(PANEL_BADGE_TRIGGER, timeRangeBadge); + uiActions.addTriggerAction(PANEL_BADGE_TRIGGER, timeRangeBadge); + + return { + ...uiActions, + }; } public stop() {} diff --git a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts new file mode 100644 index 0000000000000..66e2a4eafa880 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.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. + */ + +/* eslint-disable */ + +export { + ActionFactory +} from '../../../../../../src/plugins/ui_actions/public/actions/action_factory'; diff --git a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts new file mode 100644 index 0000000000000..f8669a4bf813f --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.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. + */ + +/* eslint-disable */ + +export { + ActionFactoryDefinition +} from '../../../../../../src/plugins/ui_actions/public/actions/action_factory_definition'; diff --git a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts new file mode 100644 index 0000000000000..db5bb3aa62a16 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './action_factory_definition'; +export * from './action_factory'; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts b/x-pack/plugins/advanced_ui_actions/public/services/index.ts similarity index 84% rename from x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts rename to x-pack/plugins/advanced_ui_actions/public/services/index.ts index ce235043b4ef6..0f8b4c8d8f409 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts +++ b/x-pack/plugins/advanced_ui_actions/public/services/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './flyout_create_drilldown'; +export * from './action_factory_service'; diff --git a/x-pack/plugins/advanced_ui_actions/public/util/index.ts b/x-pack/plugins/advanced_ui_actions/public/util/index.ts new file mode 100644 index 0000000000000..fd3ab89973348 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/util/index.ts @@ -0,0 +1,10 @@ +/* + * 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 { + UiActionsConfigurable as Configurable, + UiActionsCollectConfigProps as CollectConfigProps, +} from '../../../../../src/plugins/ui_actions/public'; diff --git a/x-pack/plugins/dashboard_enhanced/README.md b/x-pack/plugins/dashboard_enhanced/README.md new file mode 100644 index 0000000000000..d9296ae158621 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/README.md @@ -0,0 +1 @@ +# X-Pack part of Dashboard app diff --git a/x-pack/plugins/dashboard_enhanced/kibana.json b/x-pack/plugins/dashboard_enhanced/kibana.json new file mode 100644 index 0000000000000..acbca5c33295c --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "dashboardEnhanced", + "version": "kibana", + "server": true, + "ui": true, + "requiredPlugins": ["uiActions", "embeddable", "dashboard", "drilldowns"], + "configPath": ["xpack", "dashboardEnhanced"] +} diff --git a/x-pack/plugins/dashboard_enhanced/public/components/README.md b/x-pack/plugins/dashboard_enhanced/public/components/README.md new file mode 100644 index 0000000000000..8081f8a2451cf --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/README.md @@ -0,0 +1,5 @@ +# Presentation React components + +Here we keep reusable *presentation* (aka *dumb*) React components—these +components should not be connected to state and ideally should not know anything +about Kibana. diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx new file mode 100644 index 0000000000000..8e204b044a136 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx @@ -0,0 +1,54 @@ +/* + * 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. + */ + +/* eslint-disable no-console */ + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { DashboardDrilldownConfig } from '.'; + +export const dashboards = [ + { id: 'dashboard1', title: 'Dashboard 1' }, + { id: 'dashboard2', title: 'Dashboard 2' }, + { id: 'dashboard3', title: 'Dashboard 3' }, +]; + +const InteractiveDemo: React.FC = () => { + const [activeDashboardId, setActiveDashboardId] = React.useState('dashboard1'); + const [currentFilters, setCurrentFilters] = React.useState(false); + const [keepRange, setKeepRange] = React.useState(false); + + return ( + setActiveDashboardId(id)} + onCurrentFiltersToggle={() => setCurrentFilters(old => !old)} + onKeepRangeToggle={() => setKeepRange(old => !old)} + /> + ); +}; + +storiesOf('components/DashboardDrilldownConfig', module) + .add('default', () => ( + console.log('onDashboardSelect', e)} + /> + )) + .add('with switches', () => ( + console.log('onDashboardSelect', e)} + onCurrentFiltersToggle={() => console.log('onCurrentFiltersToggle')} + onKeepRangeToggle={() => console.log('onKeepRangeToggle')} + /> + )) + .add('interactive demo', () => ); diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx new file mode 100644 index 0000000000000..911ff6f632635 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx @@ -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. + */ + +test.todo('renders list of dashboards'); +test.todo('renders correct selected dashboard'); +test.todo('can change dashboard'); +test.todo('can toggle "use current filters" switch'); +test.todo('can toggle "date range" switch'); diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx new file mode 100644 index 0000000000000..b45ba602b9bb1 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui'; +import { txtChooseDestinationDashboard } from './i18n'; + +export interface DashboardItem { + id: string; + title: string; +} + +export interface DashboardDrilldownConfigProps { + activeDashboardId?: string; + dashboards: DashboardItem[]; + currentFilters?: boolean; + keepRange?: boolean; + onDashboardSelect: (dashboardId: string) => void; + onCurrentFiltersToggle?: () => void; + onKeepRangeToggle?: () => void; +} + +export const DashboardDrilldownConfig: React.FC = ({ + activeDashboardId, + dashboards, + currentFilters, + keepRange, + onDashboardSelect, + onCurrentFiltersToggle, + onKeepRangeToggle, +}) => { + // TODO: use i18n below. + return ( + <> + + ({ value: id, text: title }))} + value={activeDashboardId} + onChange={e => onDashboardSelect(e.target.value)} + /> + + {!!onCurrentFiltersToggle && ( + + + + )} + {!!onKeepRangeToggle && ( + + + + )} + + ); +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts new file mode 100644 index 0000000000000..38fe6dd150853 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtChooseDestinationDashboard = i18n.translate( + 'xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard', + { + defaultMessage: 'Choose destination dashboard', + } +); diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts new file mode 100644 index 0000000000000..b9a64a3cc17e6 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/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 * from './dashboard_drilldown_config'; diff --git a/x-pack/plugins/dashboard_enhanced/public/components/index.ts b/x-pack/plugins/dashboard_enhanced/public/components/index.ts new file mode 100644 index 0000000000000..b9a64a3cc17e6 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/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 * from './dashboard_drilldown_config'; diff --git a/x-pack/plugins/dashboard_enhanced/public/index.ts b/x-pack/plugins/dashboard_enhanced/public/index.ts new file mode 100644 index 0000000000000..53540a4a1ad2e --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { DashboardEnhancedPlugin } from './plugin'; + +export { + SetupContract as DashboardEnhancedSetupContract, + SetupDependencies as DashboardEnhancedSetupDependencies, + StartContract as DashboardEnhancedStartContract, + StartDependencies as DashboardEnhancedStartDependencies, +} from './plugin'; + +export function plugin(context: PluginInitializerContext) { + return new DashboardEnhancedPlugin(context); +} diff --git a/x-pack/plugins/dashboard_enhanced/public/mocks.ts b/x-pack/plugins/dashboard_enhanced/public/mocks.ts new file mode 100644 index 0000000000000..67dc1fd97d521 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/mocks.ts @@ -0,0 +1,27 @@ +/* + * 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 { DashboardEnhancedSetupContract, DashboardEnhancedStartContract } from '.'; + +export type Setup = jest.Mocked; +export type Start = jest.Mocked; + +const createSetupContract = (): Setup => { + const setupContract: Setup = {}; + + return setupContract; +}; + +const createStartContract = (): Start => { + const startContract: Start = {}; + + return startContract; +}; + +export const dashboardEnhancedPluginMock = { + createSetupContract, + createStartContract, +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/plugin.ts b/x-pack/plugins/dashboard_enhanced/public/plugin.ts new file mode 100644 index 0000000000000..30b3f3c080f49 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/plugin.ts @@ -0,0 +1,50 @@ +/* + * 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 { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; +import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; +import { DashboardDrilldownsService } from './services'; +import { DrilldownsSetup, DrilldownsStart } from '../../drilldowns/public'; + +export interface SetupDependencies { + uiActions: UiActionsSetup; + drilldowns: DrilldownsSetup; +} + +export interface StartDependencies { + uiActions: UiActionsStart; + drilldowns: DrilldownsStart; +} + +// eslint-disable-next-line +export interface SetupContract {} + +// eslint-disable-next-line +export interface StartContract {} + +export class DashboardEnhancedPlugin + implements Plugin { + public readonly drilldowns = new DashboardDrilldownsService(); + public readonly config: { drilldowns: { enabled: boolean } }; + + constructor(protected readonly context: PluginInitializerContext) { + this.config = context.config.get(); + } + + public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { + this.drilldowns.bootstrap(core, plugins, { + enableDrilldowns: this.config.drilldowns.enabled, + }); + + return {}; + } + + public start(core: CoreStart, plugins: StartDependencies): StartContract { + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx new file mode 100644 index 0000000000000..31ee9e29938cb --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx @@ -0,0 +1,124 @@ +/* + * 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 { + FlyoutCreateDrilldownAction, + OpenFlyoutAddDrilldownParams, +} from './flyout_create_drilldown'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks'; +import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; +import { uiActionsPluginMock } from '../../../../../../../../src/plugins/ui_actions/public/mocks'; +import { TriggerContextMapping } from '../../../../../../../../src/plugins/ui_actions/public'; +import { MockEmbeddable } from '../test_helpers'; + +const overlays = coreMock.createStart().overlays; +const drilldowns = drilldownsPluginMock.createStartContract(); +const uiActions = uiActionsPluginMock.createStartContract(); + +const actionParams: OpenFlyoutAddDrilldownParams = { + drilldowns: () => Promise.resolve(drilldowns), + overlays: () => Promise.resolve(overlays), +}; + +test('should create', () => { + expect(() => new FlyoutCreateDrilldownAction(actionParams)).not.toThrow(); +}); + +test('title is a string', () => { + expect(typeof new FlyoutCreateDrilldownAction(actionParams).getDisplayName() === 'string').toBe( + true + ); +}); + +test('icon exists', () => { + expect(typeof new FlyoutCreateDrilldownAction(actionParams).getIconType() === 'string').toBe( + true + ); +}); + +describe('isCompatible', () => { + const drilldownAction = new FlyoutCreateDrilldownAction(actionParams); + + function checkCompatibility(params: { + isEdit: boolean; + withUiActions: boolean; + isValueClickTriggerSupported: boolean; + }): Promise { + return drilldownAction.isCompatible({ + embeddable: new MockEmbeddable( + { id: '', viewMode: params.isEdit ? ViewMode.EDIT : ViewMode.VIEW }, + { + supportedTriggers: (params.isValueClickTriggerSupported + ? ['VALUE_CLICK_TRIGGER'] + : []) as Array, + uiActions: params.withUiActions ? uiActions : undefined, // dynamic actions support + } + ), + }); + } + + test("compatible if dynamicUiActions enabled, 'VALUE_CLICK_TRIGGER' is supported, in edit mode", async () => { + expect( + await checkCompatibility({ + withUiActions: true, + isEdit: true, + isValueClickTriggerSupported: true, + }) + ).toBe(true); + }); + + test('not compatible if dynamicUiActions disabled', async () => { + expect( + await checkCompatibility({ + withUiActions: false, + isEdit: true, + isValueClickTriggerSupported: true, + }) + ).toBe(false); + }); + + test("not compatible if 'VALUE_CLICK_TRIGGER' is not supported", async () => { + expect( + await checkCompatibility({ + withUiActions: true, + isEdit: true, + isValueClickTriggerSupported: false, + }) + ).toBe(false); + }); + + test('not compatible if in view mode', async () => { + expect( + await checkCompatibility({ + withUiActions: true, + isEdit: false, + isValueClickTriggerSupported: true, + }) + ).toBe(false); + }); +}); + +describe('execute', () => { + const drilldownAction = new FlyoutCreateDrilldownAction(actionParams); + test('throws error if no dynamicUiActions', async () => { + await expect( + drilldownAction.execute({ + embeddable: new MockEmbeddable({ id: '' }, {}), + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Can't execute FlyoutCreateDrilldownAction without dynamicActionsManager"` + ); + }); + + test('should open flyout', async () => { + const spy = jest.spyOn(overlays, 'openFlyout'); + await drilldownAction.execute({ + embeddable: new MockEmbeddable({ id: '' }, { uiActions }), + }); + expect(spy).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx new file mode 100644 index 0000000000000..00e74ea570a11 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx @@ -0,0 +1,74 @@ +/* + * 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 from 'react'; +import { i18n } from '@kbn/i18n'; +import { CoreStart } from 'src/core/public'; +import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public'; +import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; +import { DrilldownsStart } from '../../../../../../drilldowns/public'; +import { EmbeddableContext } from '../../../../../../../../src/plugins/embeddable/public'; + +export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; + +export interface OpenFlyoutAddDrilldownParams { + overlays: () => Promise; + drilldowns: () => Promise; +} + +export class FlyoutCreateDrilldownAction implements ActionByType { + public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN; + public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN; + public order = 12; + + constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {} + + public getDisplayName() { + return i18n.translate('xpack.dashboard.FlyoutCreateDrilldownAction.displayName', { + defaultMessage: 'Create drilldown', + }); + } + + public getIconType() { + return 'plusInCircle'; + } + + private isEmbeddableCompatible(context: EmbeddableContext) { + if (!context.embeddable.dynamicActions) return false; + const supportedTriggers = context.embeddable.supportedTriggers(); + if (!supportedTriggers || !supportedTriggers.length) return false; + return supportedTriggers.indexOf('VALUE_CLICK_TRIGGER') > -1; + } + + public async isCompatible(context: EmbeddableContext) { + const isEditMode = context.embeddable.getInput().viewMode === 'edit'; + return isEditMode && this.isEmbeddableCompatible(context); + } + + public async execute(context: EmbeddableContext) { + const overlays = await this.params.overlays(); + const drilldowns = await this.params.drilldowns(); + const dynamicActionManager = context.embeddable.dynamicActions; + + if (!dynamicActionManager) { + throw new Error(`Can't execute FlyoutCreateDrilldownAction without dynamicActionsManager`); + } + + const handle = overlays.openFlyout( + toMountPoint( + handle.close()} + placeContext={context} + viewMode={'create'} + dynamicActionManager={dynamicActionManager} + /> + ), + { + ownFocus: true, + } + ); + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts new file mode 100644 index 0000000000000..4d2db209fc961 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + FlyoutCreateDrilldownAction, + OpenFlyoutAddDrilldownParams, + OPEN_FLYOUT_ADD_DRILLDOWN, +} from './flyout_create_drilldown'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx new file mode 100644 index 0000000000000..a3f11eb976f90 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx @@ -0,0 +1,102 @@ +/* + * 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 { FlyoutEditDrilldownAction, FlyoutEditDrilldownParams } from './flyout_edit_drilldown'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks'; +import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; +import { uiActionsPluginMock } from '../../../../../../../../src/plugins/ui_actions/public/mocks'; +import { MockEmbeddable } from '../test_helpers'; + +const overlays = coreMock.createStart().overlays; +const drilldowns = drilldownsPluginMock.createStartContract(); +const uiActions = uiActionsPluginMock.createStartContract(); + +const actionParams: FlyoutEditDrilldownParams = { + drilldowns: () => Promise.resolve(drilldowns), + overlays: () => Promise.resolve(overlays), +}; + +test('should create', () => { + expect(() => new FlyoutEditDrilldownAction(actionParams)).not.toThrow(); +}); + +test('title is a string', () => { + expect(typeof new FlyoutEditDrilldownAction(actionParams).getDisplayName() === 'string').toBe( + true + ); +}); + +test('icon exists', () => { + expect(typeof new FlyoutEditDrilldownAction(actionParams).getIconType() === 'string').toBe(true); +}); + +test('MenuItem exists', () => { + expect(new FlyoutEditDrilldownAction(actionParams).MenuItem).toBeDefined(); +}); + +describe('isCompatible', () => { + const drilldownAction = new FlyoutEditDrilldownAction(actionParams); + + function checkCompatibility(params: { + isEdit: boolean; + withUiActions: boolean; + }): Promise { + return drilldownAction.isCompatible({ + embeddable: new MockEmbeddable( + { + id: '', + viewMode: params.isEdit ? ViewMode.EDIT : ViewMode.VIEW, + }, + { + uiActions: params.withUiActions ? uiActions : undefined, // dynamic actions support + } + ), + }); + } + + // TODO: need proper DynamicActionsMock and ActionFactory mock + test.todo('compatible if dynamicUiActions enabled, in edit view, and have at least 1 drilldown'); + + test('not compatible if dynamicUiActions disabled', async () => { + expect( + await checkCompatibility({ + withUiActions: false, + isEdit: true, + }) + ).toBe(false); + }); + + test('not compatible if no drilldowns', async () => { + expect( + await checkCompatibility({ + withUiActions: true, + isEdit: true, + }) + ).toBe(false); + }); +}); + +describe('execute', () => { + const drilldownAction = new FlyoutEditDrilldownAction(actionParams); + test('throws error if no dynamicUiActions', async () => { + await expect( + drilldownAction.execute({ + embeddable: new MockEmbeddable({ id: '' }, {}), + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Can't execute FlyoutEditDrilldownAction without dynamicActionsManager"` + ); + }); + + test('should open flyout', async () => { + const spy = jest.spyOn(overlays, 'openFlyout'); + await drilldownAction.execute({ + embeddable: new MockEmbeddable({ id: '' }, { uiActions }), + }); + expect(spy).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx new file mode 100644 index 0000000000000..816b757592a72 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx @@ -0,0 +1,71 @@ +/* + * 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 from 'react'; +import { CoreStart } from 'src/core/public'; +import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public'; +import { + reactToUiComponent, + toMountPoint, +} from '../../../../../../../../src/plugins/kibana_react/public'; +import { EmbeddableContext, ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; +import { DrilldownsStart } from '../../../../../../drilldowns/public'; +import { txtDisplayName } from './i18n'; +import { MenuItem } from './menu_item'; + +export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; + +export interface FlyoutEditDrilldownParams { + overlays: () => Promise; + drilldowns: () => Promise; +} + +export class FlyoutEditDrilldownAction implements ActionByType { + public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN; + public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN; + public order = 10; + + constructor(protected readonly params: FlyoutEditDrilldownParams) {} + + public getDisplayName() { + return txtDisplayName; + } + + public getIconType() { + return 'list'; + } + + MenuItem = reactToUiComponent(MenuItem); + + public async isCompatible({ embeddable }: EmbeddableContext) { + if (embeddable.getInput().viewMode !== ViewMode.EDIT) return false; + if (!embeddable.dynamicActions) return false; + return embeddable.dynamicActions.state.get().events.length > 0; + } + + public async execute(context: EmbeddableContext) { + const overlays = await this.params.overlays(); + const drilldowns = await this.params.drilldowns(); + const dynamicActionManager = context.embeddable.dynamicActions; + if (!dynamicActionManager) { + throw new Error(`Can't execute FlyoutEditDrilldownAction without dynamicActionsManager`); + } + + const handle = overlays.openFlyout( + toMountPoint( + handle.close()} + placeContext={context} + viewMode={'manage'} + dynamicActionManager={dynamicActionManager} + /> + ), + { + ownFocus: true, + } + ); + } +} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts similarity index 64% rename from x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts index ceabc6d3a9aa5..4e2e5eb7092e4 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts @@ -6,9 +6,9 @@ import { i18n } from '@kbn/i18n'; -export const txtCreateDrilldown = i18n.translate( - 'xpack.drilldowns.components.FlyoutCreateDrilldown.CreateDrilldown', +export const txtDisplayName = i18n.translate( + 'xpack.dashboard.panel.openFlyoutEditDrilldown.displayName', { - defaultMessage: 'Create drilldown', + defaultMessage: 'Manage drilldowns', } ); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx new file mode 100644 index 0000000000000..3e1b37f270708 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx @@ -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 { + FlyoutEditDrilldownAction, + FlyoutEditDrilldownParams, + OPEN_FLYOUT_EDIT_DRILLDOWN, +} from './flyout_edit_drilldown'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx new file mode 100644 index 0000000000000..be693fadf9282 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx @@ -0,0 +1,37 @@ +/* + * 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 from 'react'; +import { render, cleanup, act } from '@testing-library/react/pure'; +import { MenuItem } from './menu_item'; +import { createStateContainer } from '../../../../../../../../src/plugins/kibana_utils/common'; +import { DynamicActionManager } from '../../../../../../../../src/plugins/ui_actions/public'; +import { IEmbeddable } from '../../../../../../../../src/plugins/embeddable/public/lib/embeddables'; +import '@testing-library/jest-dom'; + +afterEach(cleanup); + +test('', () => { + const state = createStateContainer<{ events: object[] }>({ events: [] }); + const { getByText, queryByText } = render( + + ); + + expect(getByText(/manage drilldowns/i)).toBeInTheDocument(); + expect(queryByText('0')).not.toBeInTheDocument(); + + act(() => { + state.set({ events: [{}] }); + }); + + expect(queryByText('1')).toBeInTheDocument(); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx new file mode 100644 index 0000000000000..4f99fca511b07 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx @@ -0,0 +1,30 @@ +/* + * 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 from 'react'; +import { EuiNotificationBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EmbeddableContext } from '../../../../../../../../src/plugins/embeddable/public'; +import { txtDisplayName } from './i18n'; +import { useContainerState } from '../../../../../../../../src/plugins/kibana_utils/common'; + +export const MenuItem: React.FC<{ context: EmbeddableContext }> = ({ context }) => { + if (!context.embeddable.dynamicActions) + throw new Error('Flyout edit drillldown context menu item requires `dynamicActions`'); + + const { events } = useContainerState(context.embeddable.dynamicActions.state); + const count = events.length; + + return ( + + {txtDisplayName} + {count > 0 && ( + + {count} + + )} + + ); +}; diff --git a/x-pack/plugins/drilldowns/public/actions/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/index.ts similarity index 100% rename from x-pack/plugins/drilldowns/public/actions/index.ts rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/index.ts diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts new file mode 100644 index 0000000000000..9b156b0ba85b4 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_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 { Embeddable, EmbeddableInput } from '../../../../../../../src/plugins/embeddable/public/'; +import { + TriggerContextMapping, + UiActionsStart, +} from '../../../../../../../src/plugins/ui_actions/public'; + +export class MockEmbeddable extends Embeddable { + public readonly type = 'mock'; + private readonly triggers: Array = []; + constructor( + initialInput: EmbeddableInput, + params: { uiActions?: UiActionsStart; supportedTriggers?: Array } + ) { + super(initialInput, {}, undefined, params); + this.triggers = params.supportedTriggers ?? []; + } + public render(node: HTMLElement) {} + public reload() {} + public supportedTriggers(): Array { + return this.triggers; + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts new file mode 100644 index 0000000000000..4bdf03dff3531 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'src/core/public'; +import { SetupDependencies } from '../../plugin'; +import { + CONTEXT_MENU_TRIGGER, + EmbeddableContext, +} from '../../../../../../src/plugins/embeddable/public'; +import { + FlyoutCreateDrilldownAction, + FlyoutEditDrilldownAction, + OPEN_FLYOUT_ADD_DRILLDOWN, + OPEN_FLYOUT_EDIT_DRILLDOWN, +} from './actions'; +import { DrilldownsStart } from '../../../../drilldowns/public'; +import { DashboardToDashboardDrilldown } from './dashboard_to_dashboard_drilldown'; + +declare module '../../../../../../src/plugins/ui_actions/public' { + export interface ActionContextMapping { + [OPEN_FLYOUT_ADD_DRILLDOWN]: EmbeddableContext; + [OPEN_FLYOUT_EDIT_DRILLDOWN]: EmbeddableContext; + } +} + +interface BootstrapParams { + enableDrilldowns: boolean; +} + +export class DashboardDrilldownsService { + bootstrap( + core: CoreSetup<{ drilldowns: DrilldownsStart }>, + plugins: SetupDependencies, + { enableDrilldowns }: BootstrapParams + ) { + if (enableDrilldowns) { + this.setupDrilldowns(core, plugins); + } + } + + setupDrilldowns(core: CoreSetup<{ drilldowns: DrilldownsStart }>, plugins: SetupDependencies) { + const overlays = async () => (await core.getStartServices())[0].overlays; + const drilldowns = async () => (await core.getStartServices())[1].drilldowns; + const savedObjects = async () => (await core.getStartServices())[0].savedObjects.client; + + const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ overlays, drilldowns }); + plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown); + + const actionFlyoutEditDrilldown = new FlyoutEditDrilldownAction({ overlays, drilldowns }); + plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutEditDrilldown); + + const dashboardToDashboardDrilldown = new DashboardToDashboardDrilldown({ + savedObjects, + }); + plugins.drilldowns.registerDrilldown(dashboardToDashboardDrilldown); + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx new file mode 100644 index 0000000000000..95101605ce468 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +test.todo('displays all dashboard in a list'); +test.todo('does not display dashboard on which drilldown is being created'); +test.todo('updates config object correctly'); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx new file mode 100644 index 0000000000000..e463cc38b6fbf --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { CollectConfigProps } from './types'; +import { DashboardDrilldownConfig } from '../../../components/dashboard_drilldown_config'; +import { Params } from './drilldown'; + +export interface CollectConfigContainerProps extends CollectConfigProps { + params: Params; +} + +export const CollectConfigContainer: React.FC = ({ + config, + onConfig, + params: { savedObjects }, +}) => { + const [dashboards] = useState([ + { id: 'dashboard1', title: 'Dashboard 1' }, + { id: 'dashboard2', title: 'Dashboard 2' }, + { id: 'dashboard3', title: 'Dashboard 3' }, + { id: 'dashboard4', title: 'Dashboard 4' }, + ]); + + useEffect(() => { + // TODO: Load dashboards... + }, [savedObjects]); + + return ( + { + onConfig({ ...config, dashboardId }); + }} + onCurrentFiltersToggle={() => + onConfig({ + ...config, + useCurrentFilters: !config.useCurrentFilters, + }) + } + onKeepRangeToggle={() => + onConfig({ + ...config, + useCurrentDateRange: !config.useCurrentDateRange, + }) + } + /> + ); +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts new file mode 100644 index 0000000000000..e2a530b156da5 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const DASHBOARD_TO_DASHBOARD_DRILLDOWN = 'DASHBOARD_TO_DASHBOARD_DRILLDOWN'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx new file mode 100644 index 0000000000000..0fb60bb1064a1 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +describe('.isConfigValid()', () => { + test.todo('returns false for incorrect config'); + test.todo('returns true for incorrect config'); +}); + +describe('.execute()', () => { + test.todo('navigates to correct dashboard'); + test.todo( + 'when user chooses to keep current filters, current fileters are set on destination dashboard' + ); + test.todo( + 'when user chooses to keep current time range, current time range is set on destination dashboard' + ); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx new file mode 100644 index 0000000000000..9d2a378f08acd --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx @@ -0,0 +1,52 @@ +/* + * 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 from 'react'; +import { CoreStart } from 'src/core/public'; +import { reactToUiComponent } from '../../../../../../../src/plugins/kibana_react/public'; +import { PlaceContext, ActionContext, Config, CollectConfigProps } from './types'; +import { CollectConfigContainer } from './collect_config'; +import { DASHBOARD_TO_DASHBOARD_DRILLDOWN } from './constants'; +import { DrilldownDefinition as Drilldown } from '../../../../../drilldowns/public'; +import { txtGoToDashboard } from './i18n'; + +export interface Params { + savedObjects: () => Promise; +} + +export class DashboardToDashboardDrilldown + implements Drilldown { + constructor(protected readonly params: Params) {} + + public readonly id = DASHBOARD_TO_DASHBOARD_DRILLDOWN; + + public readonly order = 100; + + public readonly getDisplayName = () => txtGoToDashboard; + + public readonly euiIcon = 'dashboardApp'; + + private readonly ReactCollectConfig: React.FC = props => ( + + ); + + public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); + + public readonly createConfig = () => ({ + dashboardId: '123', + useCurrentFilters: true, + useCurrentDateRange: true, + }); + + public readonly isConfigValid = (config: Config): config is Config => { + if (!config.dashboardId) return false; + return true; + }; + + public readonly execute = () => { + alert('Go to another dashboard!'); + }; +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts new file mode 100644 index 0000000000000..98b746bafd24a --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtGoToDashboard = i18n.translate('xpack.dashboard.drilldown.goToDashboard', { + defaultMessage: 'Go to Dashboard', +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts new file mode 100644 index 0000000000000..9daa485bb6e6c --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DASHBOARD_TO_DASHBOARD_DRILLDOWN } from './constants'; +export { + DashboardToDashboardDrilldown, + Params as DashboardToDashboardDrilldownParams, +} from './drilldown'; +export { + PlaceContext as DashboardToDashboardPlaceContext, + ActionContext as DashboardToDashboardActionContext, + Config as DashboardToDashboardConfig, +} from './types'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts new file mode 100644 index 0000000000000..398a259491e3e --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EmbeddableVisTriggerContext, + EmbeddableContext, +} from '../../../../../../../src/plugins/embeddable/public'; +import { UiActionsCollectConfigProps } from '../../../../../../../src/plugins/ui_actions/public'; + +export type PlaceContext = EmbeddableContext; +export type ActionContext = EmbeddableVisTriggerContext; + +export interface Config { + dashboardId?: string; + useCurrentFilters: boolean; + useCurrentDateRange: boolean; +} + +export type CollectConfigProps = UiActionsCollectConfigProps; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts new file mode 100644 index 0000000000000..7be8f1c65da12 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/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 * from './dashboard_drilldowns_services'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/index.ts new file mode 100644 index 0000000000000..8cc3e12906531 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx b/x-pack/plugins/dashboard_enhanced/scripts/storybook.js similarity index 53% rename from x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx rename to x-pack/plugins/dashboard_enhanced/scripts/storybook.js index 5627a5d6f4522..f2cbe4135f4cb 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx +++ b/x-pack/plugins/dashboard_enhanced/scripts/storybook.js @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { DrilldownPicker } from '.'; +import { join } from 'path'; -storiesOf('components/DrilldownPicker', module).add('default', () => { - return ; +// eslint-disable-next-line +require('@kbn/storybook').runStorybookCli({ + name: 'dashboard_enhanced', + storyGlobs: [join(__dirname, '..', 'public', 'components', '**', '*.story.tsx')], }); diff --git a/x-pack/plugins/dashboard_enhanced/server/config.ts b/x-pack/plugins/dashboard_enhanced/server/config.ts new file mode 100644 index 0000000000000..b75c95d5f8832 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/server/config.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from '../../../../src/core/server'; + +export const configSchema = schema.object({ + drilldowns: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), +}); + +export type ConfigSchema = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: { + drilldowns: true, + }, +}; diff --git a/x-pack/plugins/dashboard_enhanced/server/index.ts b/x-pack/plugins/dashboard_enhanced/server/index.ts new file mode 100644 index 0000000000000..e361b9fb075ed --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/server/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { config } from './config'; + +export const plugin = () => ({ + setup() {}, + start() {}, +}); diff --git a/x-pack/plugins/drilldowns/kibana.json b/x-pack/plugins/drilldowns/kibana.json index b951c7dc1fc87..8372d87166364 100644 --- a/x-pack/plugins/drilldowns/kibana.json +++ b/x-pack/plugins/drilldowns/kibana.json @@ -3,8 +3,5 @@ "version": "kibana", "server": false, "ui": true, - "requiredPlugins": [ - "uiActions", - "embeddable" - ] + "requiredPlugins": ["uiActions", "embeddable", "advancedUiActions"] } diff --git a/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx deleted file mode 100644 index 4834cc8081374..0000000000000 --- a/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx +++ /dev/null @@ -1,52 +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 React from 'react'; -import { i18n } from '@kbn/i18n'; -import { CoreStart } from 'src/core/public'; -import { ActionByType } from '../../../../../../src/plugins/ui_actions/public'; -import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; -import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; -import { FlyoutCreateDrilldown } from '../../components/flyout_create_drilldown'; - -export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; - -export interface FlyoutCreateDrilldownActionContext { - embeddable: IEmbeddable; -} - -export interface OpenFlyoutAddDrilldownParams { - overlays: () => Promise; -} - -export class FlyoutCreateDrilldownAction implements ActionByType { - public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN; - public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN; - public order = 100; - - constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {} - - public getDisplayName() { - return i18n.translate('xpack.drilldowns.FlyoutCreateDrilldownAction.displayName', { - defaultMessage: 'Create drilldown', - }); - } - - public getIconType() { - return 'plusInCircle'; - } - - public async isCompatible({ embeddable }: FlyoutCreateDrilldownActionContext) { - return embeddable.getInput().viewMode === 'edit'; - } - - public async execute(context: FlyoutCreateDrilldownActionContext) { - const overlays = await this.params.overlays(); - const handle = overlays.openFlyout( - toMountPoint( handle.close()} />) - ); - } -} diff --git a/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx deleted file mode 100644 index f109da94fcaca..0000000000000 --- a/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx +++ /dev/null @@ -1,72 +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 React from 'react'; -import { i18n } from '@kbn/i18n'; -import { CoreStart } from 'src/core/public'; -import { EuiNotificationBadge } from '@elastic/eui'; -import { ActionByType } from '../../../../../../src/plugins/ui_actions/public'; -import { - toMountPoint, - reactToUiComponent, -} from '../../../../../../src/plugins/kibana_react/public'; -import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; -import { FormCreateDrilldown } from '../../components/form_create_drilldown'; - -export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; - -export interface FlyoutEditDrilldownActionContext { - embeddable: IEmbeddable; -} - -export interface FlyoutEditDrilldownParams { - overlays: () => Promise; -} - -const displayName = i18n.translate('xpack.drilldowns.panel.openFlyoutEditDrilldown.displayName', { - defaultMessage: 'Manage drilldowns', -}); - -// mocked data -const drilldrownCount = 2; - -export class FlyoutEditDrilldownAction implements ActionByType { - public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN; - public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN; - public order = 100; - - constructor(protected readonly params: FlyoutEditDrilldownParams) {} - - public getDisplayName() { - return displayName; - } - - public getIconType() { - return 'list'; - } - - private ReactComp: React.FC<{ context: FlyoutEditDrilldownActionContext }> = () => { - return ( - <> - {displayName}{' '} - - {drilldrownCount} - - - ); - }; - - MenuItem = reactToUiComponent(this.ReactComp); - - public async isCompatible({ embeddable }: FlyoutEditDrilldownActionContext) { - return embeddable.getInput().viewMode === 'edit' && drilldrownCount > 0; - } - - public async execute({ embeddable }: FlyoutEditDrilldownActionContext) { - const overlays = await this.params.overlays(); - overlays.openFlyout(toMountPoint()); - } -} diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx new file mode 100644 index 0000000000000..16b4d3a25d9e5 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx @@ -0,0 +1,43 @@ +/* + * 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 * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; +import { + dashboardFactory, + urlFactory, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; +import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { StubBrowserStorage } from '../../../../../../src/test_utils/public/stub_browser_storage'; +import { mockDynamicActionManager } from './test_data'; + +const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ + advancedUiActions: { + getActionFactories() { + return [dashboardFactory, urlFactory]; + }, + } as any, + storage: new Storage(new StubBrowserStorage()), + notifications: { + toasts: { + addError: (...args: any[]) => { + alert(JSON.stringify(args)); + }, + addSuccess: (...args: any[]) => { + alert(JSON.stringify(args)); + }, + } as any, + }, +}); + +storiesOf('components/FlyoutManageDrilldowns', module).add('default', () => ( + {}}> + + +)); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx new file mode 100644 index 0000000000000..6749b41e81fc7 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx @@ -0,0 +1,221 @@ +/* + * 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 from 'react'; +import { cleanup, fireEvent, render, wait } from '@testing-library/react/pure'; +import '@testing-library/jest-dom/extend-expect'; +import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; +import { + dashboardFactory, + urlFactory, +} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; +import { StubBrowserStorage } from '../../../../../../src/test_utils/public/stub_browser_storage'; +import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { mockDynamicActionManager } from './test_data'; +import { TEST_SUBJ_DRILLDOWN_ITEM } from '../list_manage_drilldowns'; +import { WELCOME_MESSAGE_TEST_SUBJ } from '../drilldown_hello_bar'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { NotificationsStart } from 'kibana/public'; +import { toastDrilldownsCRUDError } from './i18n'; + +const storage = new Storage(new StubBrowserStorage()); +const notifications = coreMock.createStart().notifications; +const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ + advancedUiActions: { + getActionFactories() { + return [dashboardFactory, urlFactory]; + }, + } as any, + storage, + notifications, +}); + +// https://github.com/elastic/kibana/issues/59469 +afterEach(cleanup); + +beforeEach(() => { + storage.clear(); + (notifications.toasts as jest.Mocked).addSuccess.mockClear(); + (notifications.toasts as jest.Mocked).addError.mockClear(); +}); + +test('Allows to manage drilldowns', async () => { + const screen = render( + + ); + + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + + // no drilldowns in the list + expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0); + + fireEvent.click(screen.getByText(/Create new/i)); + + let [createHeading, createButton] = screen.getAllByText(/Create Drilldown/i); + expect(createHeading).toBeVisible(); + expect(screen.getByLabelText(/Back/i)).toBeVisible(); + + expect(createButton).toBeDisabled(); + + // input drilldown name + const name = 'Test name'; + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: name }, + }); + + // select URL one + fireEvent.click(screen.getByText(/Go to URL/i)); + + // Input url + const URL = 'https://elastic.co'; + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: URL }, + }); + + [createHeading, createButton] = screen.getAllByText(/Create Drilldown/i); + + expect(createButton).toBeEnabled(); + fireEvent.click(createButton); + + expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); + + await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(1)); + expect(screen.getByText(name)).toBeVisible(); + const editButton = screen.getByText(/edit/i); + fireEvent.click(editButton); + + expect(screen.getByText(/Edit Drilldown/i)).toBeVisible(); + // check that wizard is prefilled with current drilldown values + expect(screen.getByLabelText(/name/i)).toHaveValue(name); + expect(screen.getByLabelText(/url/i)).toHaveValue(URL); + + // input new drilldown name + const newName = 'New drilldown name'; + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: newName }, + }); + fireEvent.click(screen.getByText(/save/i)); + + expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); + await wait(() => screen.getByText(newName)); + + // delete drilldown from edit view + fireEvent.click(screen.getByText(/edit/i)); + fireEvent.click(screen.getByText(/delete/i)); + + expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); + await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); +}); + +test('Can delete multiple drilldowns', async () => { + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + + const createDrilldown = async () => { + const oldCount = screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM).length; + fireEvent.click(screen.getByText(/Create new/i)); + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: 'test' }, + }); + fireEvent.click(screen.getByText(/Go to URL/i)); + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: 'https://elastic.co' }, + }); + fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); + await wait(() => + expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(oldCount + 1) + ); + }; + + await createDrilldown(); + await createDrilldown(); + await createDrilldown(); + + const checkboxes = screen.getAllByLabelText(/Select this drilldown/i); + expect(checkboxes).toHaveLength(3); + checkboxes.forEach(checkbox => fireEvent.click(checkbox)); + expect(screen.queryByText(/Create/i)).not.toBeInTheDocument(); + fireEvent.click(screen.getByText(/Delete \(3\)/i)); + + await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); +}); + +test('Create only mode', async () => { + const onClose = jest.fn(); + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: 'test' }, + }); + fireEvent.click(screen.getByText(/Go to URL/i)); + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: 'https://elastic.co' }, + }); + fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); + + await wait(() => expect(notifications.toasts.addSuccess).toBeCalled()); + expect(onClose).toBeCalled(); + expect(await mockDynamicActionManager.state.get().events.length).toBe(1); +}); + +test.todo("Error when can't fetch drilldown list"); + +test("Error when can't save drilldown changes", async () => { + const error = new Error('Oops'); + jest.spyOn(mockDynamicActionManager, 'createEvent').mockImplementationOnce(async () => { + throw error; + }); + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + fireEvent.click(screen.getByText(/Create new/i)); + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: 'test' }, + }); + fireEvent.click(screen.getByText(/Go to URL/i)); + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: 'https://elastic.co' }, + }); + fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); + await wait(() => + expect(notifications.toasts.addError).toBeCalledWith(error, { title: toastDrilldownsCRUDError }) + ); +}); + +test('Should show drilldown welcome message. Should be able to dismiss it', async () => { + let screen = render( + + ); + + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + + expect(screen.getByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeVisible(); + fireEvent.click(screen.getByText(/hide/i)); + expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull(); + cleanup(); + + screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull(); +}); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx new file mode 100644 index 0000000000000..f22ccc2f26f02 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx @@ -0,0 +1,332 @@ +/* + * 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, { useEffect, useState } from 'react'; +import useMountedState from 'react-use/lib/useMountedState'; +import { + AdvancedUiActionsActionFactory as ActionFactory, + AdvancedUiActionsStart, +} from '../../../../advanced_ui_actions/public'; +import { NotificationsStart } from '../../../../../../src/core/public'; +import { DrilldownWizardConfig, FlyoutDrilldownWizard } from '../flyout_drilldown_wizard'; +import { FlyoutListManageDrilldowns } from '../flyout_list_manage_drilldowns'; +import { IStorageWrapper } from '../../../../../../src/plugins/kibana_utils/public'; +import { + DynamicActionManager, + UiActionsSerializedEvent, + UiActionsSerializedAction, + VALUE_CLICK_TRIGGER, + SELECT_RANGE_TRIGGER, + TriggerContextMapping, +} from '../../../../../../src/plugins/ui_actions/public'; +import { useContainerState } from '../../../../../../src/plugins/kibana_utils/common'; +import { DrilldownListItem } from '../list_manage_drilldowns'; +import { + toastDrilldownCreated, + toastDrilldownDeleted, + toastDrilldownEdited, + toastDrilldownsCRUDError, + toastDrilldownsDeleted, +} from './i18n'; +import { DrilldownFactoryContext } from '../../types'; + +interface ConnectedFlyoutManageDrilldownsProps { + placeContext: Context; + dynamicActionManager: DynamicActionManager; + viewMode?: 'create' | 'manage'; + onClose?: () => void; +} + +/** + * Represent current state (route) of FlyoutManageDrilldowns + */ +enum Routes { + Manage = 'manage', + Create = 'create', + Edit = 'edit', +} + +export function createFlyoutManageDrilldowns({ + advancedUiActions, + storage, + notifications, +}: { + advancedUiActions: AdvancedUiActionsStart; + storage: IStorageWrapper; + notifications: NotificationsStart; +}) { + // fine to assume this is static, + // because all action factories should be registered in setup phase + const allActionFactories = advancedUiActions.getActionFactories(); + const allActionFactoriesById = allActionFactories.reduce((acc, next) => { + acc[next.id] = next; + return acc; + }, {} as Record); + + return (props: ConnectedFlyoutManageDrilldownsProps) => { + const isCreateOnly = props.viewMode === 'create'; + + const selectedTriggers: Array = React.useMemo( + () => [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER], + [] + ); + + const factoryContext: DrilldownFactoryContext = React.useMemo( + () => ({ + placeContext: props.placeContext, + triggers: selectedTriggers, + }), + [props.placeContext, selectedTriggers] + ); + + const actionFactories = useCompatibleActionFactoriesForCurrentContext( + allActionFactories, + factoryContext + ); + + const [route, setRoute] = useState( + () => (isCreateOnly ? Routes.Create : Routes.Manage) // initial state is different depending on `viewMode` + ); + const [currentEditId, setCurrentEditId] = useState(null); + + const [shouldShowWelcomeMessage, onHideWelcomeMessage] = useWelcomeMessage(storage); + + const { + drilldowns, + createDrilldown, + editDrilldown, + deleteDrilldown, + } = useDrilldownsStateManager(props.dynamicActionManager, notifications); + + /** + * isCompatible promise is not yet resolved. + * Skip rendering until it is resolved + */ + if (!actionFactories) return null; + /** + * Drilldowns are not fetched yet or error happened during fetching + * In case of error user is notified with toast + */ + if (!drilldowns) return null; + + /** + * Needed for edit mode to prefill wizard fields with data from current edited drilldown + */ + function resolveInitialDrilldownWizardConfig(): DrilldownWizardConfig | undefined { + if (route !== Routes.Edit) return undefined; + if (!currentEditId) return undefined; + const drilldownToEdit = drilldowns?.find(d => d.eventId === currentEditId); + if (!drilldownToEdit) return undefined; + + return { + actionFactory: allActionFactoriesById[drilldownToEdit.action.factoryId], + actionConfig: drilldownToEdit.action.config as object, // TODO: config is unknown, but we know it always extends object + name: drilldownToEdit.action.name, + }; + } + + /** + * Maps drilldown to list item view model + */ + function mapToDrilldownToDrilldownListItem( + drilldown: UiActionsSerializedEvent + ): DrilldownListItem { + const actionFactory = allActionFactoriesById[drilldown.action.factoryId]; + return { + id: drilldown.eventId, + drilldownName: drilldown.action.name, + actionName: actionFactory?.getDisplayName(factoryContext) ?? drilldown.action.factoryId, + icon: actionFactory?.getIconType(factoryContext), + }; + } + + switch (route) { + case Routes.Create: + case Routes.Edit: + return ( + setRoute(Routes.Manage)} + onSubmit={({ actionConfig, actionFactory, name }) => { + if (route === Routes.Create) { + createDrilldown( + { + name, + config: actionConfig, + factoryId: actionFactory.id, + }, + selectedTriggers + ); + } else { + editDrilldown( + currentEditId!, + { + name, + config: actionConfig, + factoryId: actionFactory.id, + }, + selectedTriggers + ); + } + + if (isCreateOnly) { + if (props.onClose) { + props.onClose(); + } + } else { + setRoute(Routes.Manage); + } + + setCurrentEditId(null); + }} + onDelete={() => { + deleteDrilldown(currentEditId!); + setRoute(Routes.Manage); + setCurrentEditId(null); + }} + actionFactoryContext={factoryContext} + initialDrilldownWizardConfig={resolveInitialDrilldownWizardConfig()} + /> + ); + + case Routes.Manage: + default: + return ( + { + setCurrentEditId(null); + deleteDrilldown(ids); + }} + onEdit={id => { + setCurrentEditId(id); + setRoute(Routes.Edit); + }} + onCreate={() => { + setCurrentEditId(null); + setRoute(Routes.Create); + }} + onClose={props.onClose} + /> + ); + } + }; +} + +function useCompatibleActionFactoriesForCurrentContext( + actionFactories: Array>, + context: Context +) { + const [compatibleActionFactories, setCompatibleActionFactories] = useState< + Array> + >(); + useEffect(() => { + let canceled = false; + async function updateCompatibleFactoriesForContext() { + const compatibility = await Promise.all( + actionFactories.map(factory => factory.isCompatible(context)) + ); + if (canceled) return; + setCompatibleActionFactories(actionFactories.filter((_, i) => compatibility[i])); + } + updateCompatibleFactoriesForContext(); + return () => { + canceled = true; + }; + }, [context, actionFactories]); + + return compatibleActionFactories; +} + +function useWelcomeMessage(storage: IStorageWrapper): [boolean, () => void] { + const key = `drilldowns:hidWelcomeMessage`; + const [hidWelcomeMessage, setHidWelcomeMessage] = useState(storage.get(key) ?? false); + + return [ + !hidWelcomeMessage, + () => { + if (hidWelcomeMessage) return; + setHidWelcomeMessage(true); + storage.set(key, true); + }, + ]; +} + +function useDrilldownsStateManager( + actionManager: DynamicActionManager, + notifications: NotificationsStart +) { + const { events: drilldowns } = useContainerState(actionManager.state); + const [isLoading, setIsLoading] = useState(false); + const isMounted = useMountedState(); + + async function run(op: () => Promise) { + setIsLoading(true); + try { + await op(); + } catch (e) { + notifications.toasts.addError(e, { + title: toastDrilldownsCRUDError, + }); + if (!isMounted) return; + setIsLoading(false); + return; + } + } + + async function createDrilldown( + action: UiActionsSerializedAction, + selectedTriggers: Array + ) { + await run(async () => { + await actionManager.createEvent(action, selectedTriggers); + notifications.toasts.addSuccess({ + title: toastDrilldownCreated.title, + text: toastDrilldownCreated.text(action.name), + }); + }); + } + + async function editDrilldown( + drilldownId: string, + action: UiActionsSerializedAction, + selectedTriggers: Array + ) { + await run(async () => { + await actionManager.updateEvent(drilldownId, action, selectedTriggers); + notifications.toasts.addSuccess({ + title: toastDrilldownEdited.title, + text: toastDrilldownEdited.text(action.name), + }); + }); + } + + async function deleteDrilldown(drilldownIds: string | string[]) { + await run(async () => { + drilldownIds = Array.isArray(drilldownIds) ? drilldownIds : [drilldownIds]; + await actionManager.deleteEvents(drilldownIds); + notifications.toasts.addSuccess( + drilldownIds.length === 1 + ? { + title: toastDrilldownDeleted.title, + text: toastDrilldownDeleted.text, + } + : { + title: toastDrilldownsDeleted.title, + text: toastDrilldownsDeleted.text(drilldownIds.length), + } + ); + }); + } + + return { drilldowns, isLoading, createDrilldown, editDrilldown, deleteDrilldown }; +} diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts new file mode 100644 index 0000000000000..70f4d735e2a74 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const toastDrilldownCreated = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle', + { + defaultMessage: 'Drilldown created', + } + ), + text: (drilldownName: string) => + i18n.translate('xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText', { + defaultMessage: 'You created "{drilldownName}"', + values: { + drilldownName, + }, + }), +}; + +export const toastDrilldownEdited = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle', + { + defaultMessage: 'Drilldown edited', + } + ), + text: (drilldownName: string) => + i18n.translate('xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText', { + defaultMessage: 'You edited "{drilldownName}"', + values: { + drilldownName, + }, + }), +}; + +export const toastDrilldownDeleted = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle', + { + defaultMessage: 'Drilldown deleted', + } + ), + text: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText', + { + defaultMessage: 'You deleted a drilldown', + } + ), +}; + +export const toastDrilldownsDeleted = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle', + { + defaultMessage: 'Drilldowns deleted', + } + ), + text: (n: number) => + i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText', + { + defaultMessage: 'You deleted {n} drilldowns', + values: { + n, + }, + } + ), +}; + +export const toastDrilldownsCRUDError = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle', + { + defaultMessage: 'Error saving drilldown', + description: 'Title for generic error toast when persisting drilldown updates failed', + } +); + +export const toastDrilldownsFetchError = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsFetchErrorTitle', + { + defaultMessage: 'Error fetching drilldowns', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts new file mode 100644 index 0000000000000..f084a3e563c23 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/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 * from './connected_flyout_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts new file mode 100644 index 0000000000000..b8deaa8b842bc --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; +import { + DynamicActionManager, + DynamicActionManagerState, + UiActionsSerializedAction, + TriggerContextMapping, +} from '../../../../../../src/plugins/ui_actions/public'; +import { createStateContainer } from '../../../../../../src/plugins/kibana_utils/common'; + +class MockDynamicActionManager implements PublicMethodsOf { + public readonly state = createStateContainer({ + isFetchingEvents: false, + fetchCount: 0, + events: [], + }); + + async count() { + return this.state.get().events.length; + } + + async list() { + return this.state.get().events; + } + + async createEvent( + action: UiActionsSerializedAction, + triggers: Array + ) { + const event = { + action, + triggers, + eventId: uuid(), + }; + const state = this.state.get(); + this.state.set({ + ...state, + events: [...state.events, event], + }); + } + + async deleteEvents(eventIds: string[]) { + const state = this.state.get(); + let events = state.events; + + eventIds.forEach(id => { + events = events.filter(e => e.eventId !== id); + }); + + this.state.set({ + ...state, + events, + }); + } + + async updateEvent( + eventId: string, + action: UiActionsSerializedAction, + triggers: Array + ) { + const state = this.state.get(); + const events = state.events; + const idx = events.findIndex(e => e.eventId === eventId); + const event = { + eventId, + action, + triggers, + }; + + this.state.set({ + ...state, + events: [...events.slice(0, idx), event, ...events.slice(idx + 1)], + }); + } + + async deleteEvent() { + throw new Error('not implemented'); + } + + async start() {} + async stop() {} +} + +export const mockDynamicActionManager = (new MockDynamicActionManager() as unknown) as DynamicActionManager; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx index 7a9e19342f27c..c4a4630397f1c 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx @@ -8,6 +8,16 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; import { DrilldownHelloBar } from '.'; -storiesOf('components/DrilldownHelloBar', module).add('default', () => { - return ; -}); +const Demo = () => { + const [show, setShow] = React.useState(true); + return show ? ( + { + setShow(false); + }} + /> + ) : null; +}; + +storiesOf('components/DrilldownHelloBar', module).add('default', () => ); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx index 1ef714f7b86e2..8c6739a8ad6c8 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx @@ -5,22 +5,58 @@ */ import React from 'react'; +import { + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiTextColor, + EuiText, + EuiLink, + EuiSpacer, + EuiButtonEmpty, + EuiIcon, +} from '@elastic/eui'; +import { txtHideHelpButtonLabel, txtHelpText, txtViewDocsLinkLabel } from './i18n'; export interface DrilldownHelloBarProps { docsLink?: string; + onHideClick?: () => void; } -/** - * @todo https://github.com/elastic/kibana/issues/55311 - */ -export const DrilldownHelloBar: React.FC = ({ docsLink }) => { +export const WELCOME_MESSAGE_TEST_SUBJ = 'drilldowns-welcome-message-test-subj'; + +export const DrilldownHelloBar: React.FC = ({ + docsLink, + onHideClick = () => {}, +}) => { return ( -
+ + +
+ +
+
+ + + {txtHelpText} + + {docsLink && ( + <> + + {txtViewDocsLinkLabel} + + )} + + + + {txtHideHelpButtonLabel} + + + + } + /> ); }; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts new file mode 100644 index 0000000000000..63dc95dabc0fb --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtHelpText = i18n.translate( + 'xpack.drilldowns.components.DrilldownHelloBar.helpText', + { + defaultMessage: + 'Drilldowns provide the ability to define a new behavior when interacting with a panel. You can add multiple options or simply override the default filtering behavior.', + } +); + +export const txtViewDocsLinkLabel = i18n.translate( + 'xpack.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel', + { + defaultMessage: 'View docs', + } +); + +export const txtHideHelpButtonLabel = i18n.translate( + 'xpack.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel', + { + defaultMessage: 'Hide', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx deleted file mode 100644 index 3748fc666c81c..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx +++ /dev/null @@ -1,21 +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 React from 'react'; - -// eslint-disable-next-line -export interface DrilldownPickerProps {} - -export const DrilldownPicker: React.FC = () => { - return ( - - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx deleted file mode 100644 index 4f024b7d9cd6a..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx +++ /dev/null @@ -1,24 +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. - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { EuiFlyout } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import { FlyoutCreateDrilldown } from '.'; - -storiesOf('components/FlyoutCreateDrilldown', module) - .add('default', () => { - return ; - }) - .add('open in flyout', () => { - return ( - - - - ); - }); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx deleted file mode 100644 index b45ac9197c7e0..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx +++ /dev/null @@ -1,34 +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 React from 'react'; -import { EuiButton } from '@elastic/eui'; -import { FormCreateDrilldown } from '../form_create_drilldown'; -import { FlyoutFrame } from '../flyout_frame'; -import { txtCreateDrilldown } from './i18n'; -import { FlyoutCreateDrilldownActionContext } from '../../actions'; - -export interface FlyoutCreateDrilldownProps { - context: FlyoutCreateDrilldownActionContext; - onClose?: () => void; -} - -export const FlyoutCreateDrilldown: React.FC = ({ - context, - onClose, -}) => { - const footer = ( - {}} fill> - {txtCreateDrilldown} - - ); - - return ( - - - - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx new file mode 100644 index 0000000000000..152cd393b9d3e --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-console */ + +import * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { FlyoutDrilldownWizard } from '.'; +import { + dashboardFactory, + urlFactory, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; + +storiesOf('components/FlyoutDrilldownWizard', module) + .add('default', () => { + return ; + }) + .add('open in flyout - create', () => { + return ( + {}}> + {}} + drilldownActionFactories={[urlFactory, dashboardFactory]} + /> + + ); + }) + .add('open in flyout - edit', () => { + return ( + {}}> + {}} + drilldownActionFactories={[urlFactory, dashboardFactory]} + initialDrilldownWizardConfig={{ + name: 'My fancy drilldown', + actionFactory: urlFactory as any, + actionConfig: { + url: 'https://elastic.co', + openInNewTab: true, + }, + }} + mode={'edit'} + /> + + ); + }) + .add('open in flyout - edit, just 1 action type', () => { + return ( + {}}> + {}} + drilldownActionFactories={[dashboardFactory]} + initialDrilldownWizardConfig={{ + name: 'My fancy drilldown', + actionFactory: urlFactory as any, + actionConfig: { + url: 'https://elastic.co', + openInNewTab: true, + }, + }} + mode={'edit'} + /> + + ); + }); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx new file mode 100644 index 0000000000000..faa965a98a4bb --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { EuiButton, EuiSpacer } from '@elastic/eui'; +import { FormDrilldownWizard } from '../form_drilldown_wizard'; +import { FlyoutFrame } from '../flyout_frame'; +import { + txtCreateDrilldownButtonLabel, + txtCreateDrilldownTitle, + txtDeleteDrilldownButtonLabel, + txtEditDrilldownButtonLabel, + txtEditDrilldownTitle, +} from './i18n'; +import { DrilldownHelloBar } from '../drilldown_hello_bar'; +import { AdvancedUiActionsActionFactory as ActionFactory } from '../../../../advanced_ui_actions/public'; + +export interface DrilldownWizardConfig { + name: string; + actionFactory?: ActionFactory; + actionConfig?: ActionConfig; +} + +export interface FlyoutDrilldownWizardProps { + drilldownActionFactories: Array>; + + onSubmit?: (drilldownWizardConfig: Required) => void; + onDelete?: () => void; + onClose?: () => void; + onBack?: () => void; + + mode?: 'create' | 'edit'; + initialDrilldownWizardConfig?: DrilldownWizardConfig; + + showWelcomeMessage?: boolean; + onWelcomeHideClick?: () => void; + + actionFactoryContext?: object; +} + +export function FlyoutDrilldownWizard({ + onClose, + onBack, + onSubmit = () => {}, + initialDrilldownWizardConfig, + mode = 'create', + onDelete = () => {}, + showWelcomeMessage = true, + onWelcomeHideClick, + drilldownActionFactories, + actionFactoryContext, +}: FlyoutDrilldownWizardProps) { + const [wizardConfig, setWizardConfig] = useState( + () => + initialDrilldownWizardConfig ?? { + name: '', + } + ); + + const isActionValid = ( + config: DrilldownWizardConfig + ): config is Required => { + if (!wizardConfig.name) return false; + if (!wizardConfig.actionFactory) return false; + if (!wizardConfig.actionConfig) return false; + + return wizardConfig.actionFactory.isConfigValid(wizardConfig.actionConfig); + }; + + const footer = ( + { + if (isActionValid(wizardConfig)) { + onSubmit(wizardConfig); + } + }} + fill + isDisabled={!isActionValid(wizardConfig)} + > + {mode === 'edit' ? txtEditDrilldownButtonLabel : txtCreateDrilldownButtonLabel} + + ); + + return ( + } + > + { + setWizardConfig({ + ...wizardConfig, + name: newName, + }); + }} + actionConfig={wizardConfig.actionConfig} + onActionConfigChange={newActionConfig => { + setWizardConfig({ + ...wizardConfig, + actionConfig: newActionConfig, + }); + }} + currentActionFactory={wizardConfig.actionFactory} + onActionFactoryChange={actionFactory => { + if (!actionFactory) { + setWizardConfig({ + ...wizardConfig, + actionFactory: undefined, + actionConfig: undefined, + }); + } else { + setWizardConfig({ + ...wizardConfig, + actionFactory, + actionConfig: actionFactory.createConfig(), + }); + } + }} + actionFactories={drilldownActionFactories} + actionFactoryContext={actionFactoryContext!} + /> + {mode === 'edit' && ( + <> + + + {txtDeleteDrilldownButtonLabel} + + + )} + + ); +} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts new file mode 100644 index 0000000000000..a4a2754a444ab --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtCreateDrilldownTitle = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle', + { + defaultMessage: 'Create Drilldown', + } +); + +export const txtEditDrilldownTitle = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle', + { + defaultMessage: 'Edit Drilldown', + } +); + +export const txtCreateDrilldownButtonLabel = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownButtonLabel', + { + defaultMessage: 'Create drilldown', + } +); + +export const txtEditDrilldownButtonLabel = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownButtonLabel', + { + defaultMessage: 'Save', + } +); + +export const txtDeleteDrilldownButtonLabel = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel', + { + defaultMessage: 'Delete drilldown', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts new file mode 100644 index 0000000000000..96ed23bf112c9 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/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 * from './flyout_drilldown_wizard'; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx index 2715637f6392f..cb223db556f56 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx @@ -21,6 +21,13 @@ storiesOf('components/FlyoutFrame', module) .add('with onClose', () => { return console.log('onClose')}>test; }) + .add('with onBack', () => { + return ( + console.log('onClose')} title={'Title'}> + test + + ); + }) .add('custom footer', () => { return click me!}>test; }) diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx index b5fb52fcf5c18..0a3989487745f 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx @@ -6,9 +6,11 @@ import React from 'react'; import { render } from 'react-dom'; -import { render as renderTestingLibrary, fireEvent } from '@testing-library/react'; +import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure'; import { FlyoutFrame } from '.'; +afterEach(cleanup); + describe('', () => { test('renders without crashing', () => { const div = document.createElement('div'); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx index 2945cfd739482..b55cbd88d0dc0 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx @@ -13,13 +13,16 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, + EuiButtonIcon, } from '@elastic/eui'; -import { txtClose } from './i18n'; +import { txtClose, txtBack } from './i18n'; export interface FlyoutFrameProps { title?: React.ReactNode; footer?: React.ReactNode; + banner?: React.ReactNode; onClose?: () => void; + onBack?: () => void; } /** @@ -30,11 +33,31 @@ export const FlyoutFrame: React.FC = ({ footer, onClose, children, + onBack, + banner, }) => { - const headerFragment = title && ( + const headerFragment = (title || onBack) && ( -

{title}

+ + {onBack && ( + +
+ +
+
+ )} + {title && ( + +

{title}

+
+ )} +
); @@ -64,7 +87,7 @@ export const FlyoutFrame: React.FC = ({ return ( <> {headerFragment} - {children} + {children} {footerFragment} ); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts index 257d7d36dbee1..23af89ebf9bc7 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts @@ -6,6 +6,10 @@ import { i18n } from '@kbn/i18n'; -export const txtClose = i18n.translate('xpack.drilldowns.components.FlyoutFrame.Close', { +export const txtClose = i18n.translate('xpack.drilldowns.components.FlyoutFrame.CloseButtonLabel', { defaultMessage: 'Close', }); + +export const txtBack = i18n.translate('xpack.drilldowns.components.FlyoutFrame.BackButtonLabel', { + defaultMessage: 'Back', +}); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx new file mode 100644 index 0000000000000..0529f0451b16a --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { FlyoutListManageDrilldowns } from './flyout_list_manage_drilldowns'; + +storiesOf('components/FlyoutListManageDrilldowns', module).add('default', () => ( + {}}> + + +)); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx new file mode 100644 index 0000000000000..a44a7ccccb4dc --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx @@ -0,0 +1,46 @@ +/* + * 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 from 'react'; +import { FlyoutFrame } from '../flyout_frame'; +import { DrilldownListItem, ListManageDrilldowns } from '../list_manage_drilldowns'; +import { txtManageDrilldowns } from './i18n'; +import { DrilldownHelloBar } from '../drilldown_hello_bar'; + +export interface FlyoutListManageDrilldownsProps { + drilldowns: DrilldownListItem[]; + onClose?: () => void; + onCreate?: () => void; + onEdit?: (drilldownId: string) => void; + onDelete?: (drilldownIds: string[]) => void; + showWelcomeMessage?: boolean; + onWelcomeHideClick?: () => void; +} + +export function FlyoutListManageDrilldowns({ + drilldowns, + onClose = () => {}, + onCreate, + onDelete, + onEdit, + showWelcomeMessage = true, + onWelcomeHideClick, +}: FlyoutListManageDrilldownsProps) { + return ( + } + > + + + ); +} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts new file mode 100644 index 0000000000000..0dd4e37d4dddd --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtManageDrilldowns = i18n.translate( + 'xpack.drilldowns.components.FlyoutListManageDrilldowns.manageDrilldownsTitle', + { + defaultMessage: 'Manage Drilldowns', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts new file mode 100644 index 0000000000000..f8c9d224fb292 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/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 * from './flyout_list_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx deleted file mode 100644 index e7e1d67473e8c..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx +++ /dev/null @@ -1,34 +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. - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { EuiFlyout } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import { FormCreateDrilldown } from '.'; - -const DemoEditName: React.FC = () => { - const [name, setName] = React.useState(''); - - return ; -}; - -storiesOf('components/FormCreateDrilldown', module) - .add('default', () => { - return ; - }) - .add('[name=foobar]', () => { - return ; - }) - .add('can edit name', () => ) - .add('open in flyout', () => { - return ( - - - - ); - }); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx deleted file mode 100644 index 4422de604092b..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx +++ /dev/null @@ -1,52 +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 React from 'react'; -import { EuiForm, EuiFormRow, EuiFieldText } from '@elastic/eui'; -import { DrilldownHelloBar } from '../drilldown_hello_bar'; -import { txtNameOfDrilldown, txtUntitledDrilldown, txtDrilldownAction } from './i18n'; -import { DrilldownPicker } from '../drilldown_picker'; - -const noop = () => {}; - -export interface FormCreateDrilldownProps { - name?: string; - onNameChange?: (name: string) => void; -} - -export const FormCreateDrilldown: React.FC = ({ - name = '', - onNameChange = noop, -}) => { - const nameFragment = ( - - onNameChange(event.target.value)} - data-test-subj="dynamicActionNameInput" - /> - - ); - - const triggerPicker =
Trigger Picker will be here
; - const actionPicker = ( - - - - ); - - return ( - <> - - {nameFragment} - {triggerPicker} - {actionPicker} - - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx new file mode 100644 index 0000000000000..2fc35eb6b5298 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx @@ -0,0 +1,29 @@ +/* + * 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 * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { FormDrilldownWizard } from '.'; + +const DemoEditName: React.FC = () => { + const [name, setName] = React.useState(''); + + return ( + <> + {' '} +
name: {name}
+ + ); +}; + +storiesOf('components/FormDrilldownWizard', module) + .add('default', () => { + return ; + }) + .add('[name=foobar]', () => { + return ; + }) + .add('can edit name', () => ); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx similarity index 70% rename from x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx rename to x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx index 6691966e47e64..4560773cc8a6d 100644 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx @@ -6,21 +6,23 @@ import React from 'react'; import { render } from 'react-dom'; -import { FormCreateDrilldown } from '.'; -import { render as renderTestingLibrary, fireEvent } from '@testing-library/react'; +import { FormDrilldownWizard } from './form_drilldown_wizard'; +import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure'; import { txtNameOfDrilldown } from './i18n'; -describe('', () => { +afterEach(cleanup); + +describe('', () => { test('renders without crashing', () => { const div = document.createElement('div'); - render( {}} />, div); + render( {}} actionFactoryContext={{}} />, div); }); describe('[name=]', () => { test('if name not provided, uses to empty string', () => { const div = document.createElement('div'); - render(, div); + render(, div); const input = div.querySelector( '[data-test-subj="dynamicActionNameInput"]' @@ -29,10 +31,10 @@ describe('', () => { expect(input?.value).toBe(''); }); - test('can set name input field value', () => { + test('can set initial name input field value', () => { const div = document.createElement('div'); - render(, div); + render(, div); const input = div.querySelector( '[data-test-subj="dynamicActionNameInput"]' @@ -40,7 +42,7 @@ describe('', () => { expect(input?.value).toBe('foo'); - render(, div); + render(, div); expect(input?.value).toBe('bar'); }); @@ -48,7 +50,7 @@ describe('', () => { test('fires onNameChange callback on name change', () => { const onNameChange = jest.fn(); const utils = renderTestingLibrary( - + ); const input = utils.getByLabelText(txtNameOfDrilldown); diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx new file mode 100644 index 0000000000000..bdafaaf07873c --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx @@ -0,0 +1,79 @@ +/* + * 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 from 'react'; +import { EuiFieldText, EuiForm, EuiFormRow, EuiSpacer } from '@elastic/eui'; +import { txtDrilldownAction, txtNameOfDrilldown, txtUntitledDrilldown } from './i18n'; +import { + AdvancedUiActionsActionFactory as ActionFactory, + ActionWizard, +} from '../../../../advanced_ui_actions/public'; + +const noopFn = () => {}; + +export interface FormDrilldownWizardProps { + name?: string; + onNameChange?: (name: string) => void; + + currentActionFactory?: ActionFactory; + onActionFactoryChange?: (actionFactory: ActionFactory | null) => void; + actionFactoryContext: object; + + actionConfig?: object; + onActionConfigChange?: (config: object) => void; + + actionFactories?: ActionFactory[]; +} + +export const FormDrilldownWizard: React.FC = ({ + name = '', + actionConfig, + currentActionFactory, + onNameChange = noopFn, + onActionConfigChange = noopFn, + onActionFactoryChange = noopFn, + actionFactories = [], + actionFactoryContext, +}) => { + const nameFragment = ( + + onNameChange(event.target.value)} + data-test-subj="dynamicActionNameInput" + /> + + ); + + const actionWizard = ( + 1 ? txtDrilldownAction : undefined} + fullWidth={true} + > + onActionFactoryChange(actionFactory)} + onConfigChange={config => onActionConfigChange(config)} + context={actionFactoryContext} + /> + + ); + + return ( + <> + + {nameFragment} + + {actionWizard} + + + ); +}; diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts similarity index 89% rename from x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts rename to x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts index 4c0e287935edd..e9b19ab0afa97 100644 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export const txtNameOfDrilldown = i18n.translate( 'xpack.drilldowns.components.FormCreateDrilldown.nameOfDrilldown', { - defaultMessage: 'Name of drilldown', + defaultMessage: 'Name', } ); @@ -23,6 +23,6 @@ export const txtUntitledDrilldown = i18n.translate( export const txtDrilldownAction = i18n.translate( 'xpack.drilldowns.components.FormCreateDrilldown.drilldownAction', { - defaultMessage: 'Drilldown action', + defaultMessage: 'Action', } ); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx similarity index 85% rename from x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx rename to x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx index c2c5a7e435b39..4aea824de00d7 100644 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './form_create_drilldown'; +export * from './form_drilldown_wizard'; diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts new file mode 100644 index 0000000000000..fbc7c9dcfb4a1 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtCreateDrilldown = i18n.translate( + 'xpack.drilldowns.components.ListManageDrilldowns.createDrilldownButtonLabel', + { + defaultMessage: 'Create new', + } +); + +export const txtEditDrilldown = i18n.translate( + 'xpack.drilldowns.components.ListManageDrilldowns.editDrilldownButtonLabel', + { + defaultMessage: 'Edit', + } +); + +export const txtDeleteDrilldowns = (count: number) => + i18n.translate('xpack.drilldowns.components.ListManageDrilldowns.deleteDrilldownsButtonLabel', { + defaultMessage: 'Delete ({count})', + values: { + count, + }, + }); + +export const txtSelectDrilldown = i18n.translate( + 'xpack.drilldowns.components.ListManageDrilldowns.selectThisDrilldownCheckboxLabel', + { + defaultMessage: 'Select this drilldown', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx new file mode 100644 index 0000000000000..82b6ce27af6d4 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx @@ -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 * from './list_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx new file mode 100644 index 0000000000000..eafe50bab2016 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx @@ -0,0 +1,19 @@ +/* + * 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 * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { ListManageDrilldowns } from './list_manage_drilldowns'; + +storiesOf('components/ListManageDrilldowns', module).add('default', () => ( + +)); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx new file mode 100644 index 0000000000000..4a4d67b08b1d3 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { cleanup, fireEvent, render } from '@testing-library/react/pure'; +import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global +import { + DrilldownListItem, + ListManageDrilldowns, + TEST_SUBJ_DRILLDOWN_ITEM, +} from './list_manage_drilldowns'; + +// TODO: for some reason global cleanup from RTL doesn't work +// afterEach is not available for it globally during setup +afterEach(cleanup); + +const drilldowns: DrilldownListItem[] = [ + { id: '1', actionName: 'Dashboard', drilldownName: 'Drilldown 1' }, + { id: '2', actionName: 'Dashboard', drilldownName: 'Drilldown 2' }, + { id: '3', actionName: 'Dashboard', drilldownName: 'Drilldown 3' }, +]; + +test('Render list of drilldowns', () => { + const screen = render(); + expect(screen.getAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(drilldowns.length); +}); + +test('Emit onEdit() when clicking on edit drilldown', () => { + const fn = jest.fn(); + const screen = render(); + + const editButtons = screen.getAllByText('Edit'); + expect(editButtons).toHaveLength(drilldowns.length); + fireEvent.click(editButtons[1]); + expect(fn).toBeCalledWith(drilldowns[1].id); +}); + +test('Emit onCreate() when clicking on create drilldown', () => { + const fn = jest.fn(); + const screen = render(); + fireEvent.click(screen.getByText('Create new')); + expect(fn).toBeCalled(); +}); + +test('Delete button is not visible when non is selected', () => { + const fn = jest.fn(); + const screen = render(); + expect(screen.queryByText(/Delete/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Create/i)).toBeInTheDocument(); +}); + +test('Can delete drilldowns', () => { + const fn = jest.fn(); + const screen = render(); + + const checkboxes = screen.getAllByLabelText(/Select this drilldown/i); + expect(checkboxes).toHaveLength(3); + + fireEvent.click(checkboxes[1]); + fireEvent.click(checkboxes[2]); + + expect(screen.queryByText(/Create/i)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText(/Delete \(2\)/i)); + + expect(fn).toBeCalledWith([drilldowns[1].id, drilldowns[2].id]); +}); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx new file mode 100644 index 0000000000000..5a15781a1faf2 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx @@ -0,0 +1,116 @@ +/* + * 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 { + EuiBasicTable, + EuiBasicTableColumn, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiTextColor, +} from '@elastic/eui'; +import React, { useState } from 'react'; +import { + txtCreateDrilldown, + txtDeleteDrilldowns, + txtEditDrilldown, + txtSelectDrilldown, +} from './i18n'; + +export interface DrilldownListItem { + id: string; + actionName: string; + drilldownName: string; + icon?: string; +} + +export interface ListManageDrilldownsProps { + drilldowns: DrilldownListItem[]; + + onEdit?: (id: string) => void; + onCreate?: () => void; + onDelete?: (ids: string[]) => void; +} + +const noop = () => {}; + +export const TEST_SUBJ_DRILLDOWN_ITEM = 'list-manage-drilldowns-item'; + +export function ListManageDrilldowns({ + drilldowns, + onEdit = noop, + onCreate = noop, + onDelete = noop, +}: ListManageDrilldownsProps) { + const [selectedDrilldowns, setSelectedDrilldowns] = useState([]); + + const columns: Array> = [ + { + field: 'drilldownName', + name: 'Name', + truncateText: true, + width: '50%', + }, + { + name: 'Action', + render: (drilldown: DrilldownListItem) => ( + + {drilldown.icon && ( + + + + )} + + {drilldown.actionName} + + + ), + }, + { + align: 'right', + render: (drilldown: DrilldownListItem) => ( + onEdit(drilldown.id)}> + {txtEditDrilldown} + + ), + }, + ]; + + return ( + <> + { + setSelectedDrilldowns(selection.map(drilldown => drilldown.id)); + }, + selectableMessage: () => txtSelectDrilldown, + }} + rowProps={{ + 'data-test-subj': TEST_SUBJ_DRILLDOWN_ITEM, + }} + hasActions={true} + /> + + {selectedDrilldowns.length === 0 ? ( + onCreate()}> + {txtCreateDrilldown} + + ) : ( + onDelete(selectedDrilldowns)}> + {txtDeleteDrilldowns(selectedDrilldowns.length)} + + )} + + ); +} diff --git a/x-pack/plugins/drilldowns/public/index.ts b/x-pack/plugins/drilldowns/public/index.ts index 63e7a12235462..044e29c671de4 100644 --- a/x-pack/plugins/drilldowns/public/index.ts +++ b/x-pack/plugins/drilldowns/public/index.ts @@ -7,12 +7,14 @@ import { DrilldownsPlugin } from './plugin'; export { - DrilldownsSetupContract, - DrilldownsSetupDependencies, - DrilldownsStartContract, - DrilldownsStartDependencies, + SetupContract as DrilldownsSetup, + SetupDependencies as DrilldownsSetupDependencies, + StartContract as DrilldownsStart, + StartDependencies as DrilldownsStartDependencies, } from './plugin'; export function plugin() { return new DrilldownsPlugin(); } + +export { DrilldownDefinition } from './types'; diff --git a/x-pack/plugins/drilldowns/public/mocks.ts b/x-pack/plugins/drilldowns/public/mocks.ts index bfade1674072a..18816243a3572 100644 --- a/x-pack/plugins/drilldowns/public/mocks.ts +++ b/x-pack/plugins/drilldowns/public/mocks.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DrilldownsSetupContract, DrilldownsStartContract } from '.'; +import { DrilldownsSetup, DrilldownsStart } from '.'; -export type Setup = jest.Mocked; -export type Start = jest.Mocked; +export type Setup = jest.Mocked; +export type Start = jest.Mocked; const createSetupContract = (): Setup => { const setupContract: Setup = { @@ -17,12 +17,14 @@ const createSetupContract = (): Setup => { }; const createStartContract = (): Start => { - const startContract: Start = {}; + const startContract: Start = { + FlyoutManageDrilldowns: jest.fn(), + }; return startContract; }; -export const bfetchPluginMock = { +export const drilldownsPluginMock = { createSetupContract, createStartContract, }; diff --git a/x-pack/plugins/drilldowns/public/plugin.ts b/x-pack/plugins/drilldowns/public/plugin.ts index b89172541b91e..bbc06847d5842 100644 --- a/x-pack/plugins/drilldowns/public/plugin.ts +++ b/x-pack/plugins/drilldowns/public/plugin.ts @@ -6,52 +6,46 @@ import { CoreStart, CoreSetup, Plugin } from 'src/core/public'; import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; -import { DrilldownService } from './service'; -import { - FlyoutCreateDrilldownActionContext, - FlyoutEditDrilldownActionContext, - OPEN_FLYOUT_ADD_DRILLDOWN, - OPEN_FLYOUT_EDIT_DRILLDOWN, -} from './actions'; - -export interface DrilldownsSetupDependencies { +import { AdvancedUiActionsSetup, AdvancedUiActionsStart } from '../../advanced_ui_actions/public'; +import { DrilldownService, DrilldownServiceSetupContract } from './services'; +import { createFlyoutManageDrilldowns } from './components/connected_flyout_manage_drilldowns'; +import { Storage } from '../../../../src/plugins/kibana_utils/public'; + +export interface SetupDependencies { uiActions: UiActionsSetup; + advancedUiActions: AdvancedUiActionsSetup; } -export interface DrilldownsStartDependencies { +export interface StartDependencies { uiActions: UiActionsStart; + advancedUiActions: AdvancedUiActionsStart; } -export type DrilldownsSetupContract = Pick; +export type SetupContract = DrilldownServiceSetupContract; // eslint-disable-next-line -export interface DrilldownsStartContract {} - -declare module '../../../../src/plugins/ui_actions/public' { - export interface ActionContextMapping { - [OPEN_FLYOUT_ADD_DRILLDOWN]: FlyoutCreateDrilldownActionContext; - [OPEN_FLYOUT_EDIT_DRILLDOWN]: FlyoutEditDrilldownActionContext; - } +export interface StartContract { + FlyoutManageDrilldowns: ReturnType; } export class DrilldownsPlugin - implements - Plugin< - DrilldownsSetupContract, - DrilldownsStartContract, - DrilldownsSetupDependencies, - DrilldownsStartDependencies - > { + implements Plugin { private readonly service = new DrilldownService(); - public setup(core: CoreSetup, plugins: DrilldownsSetupDependencies): DrilldownsSetupContract { - this.service.bootstrap(core, plugins); + public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { + const setup = this.service.setup(core, plugins); - return this.service; + return setup; } - public start(core: CoreStart, plugins: DrilldownsStartDependencies): DrilldownsStartContract { - return {}; + public start(core: CoreStart, plugins: StartDependencies): StartContract { + return { + FlyoutManageDrilldowns: createFlyoutManageDrilldowns({ + advancedUiActions: plugins.advancedUiActions, + storage: new Storage(localStorage), + notifications: core.notifications, + }), + }; } public stop() {} diff --git a/x-pack/plugins/drilldowns/public/service/drilldown_service.ts b/x-pack/plugins/drilldowns/public/service/drilldown_service.ts deleted file mode 100644 index 7745c30b4e335..0000000000000 --- a/x-pack/plugins/drilldowns/public/service/drilldown_service.ts +++ /dev/null @@ -1,32 +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 { CoreSetup } from 'src/core/public'; -// import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public'; -import { FlyoutCreateDrilldownAction, FlyoutEditDrilldownAction } from '../actions'; -import { DrilldownsSetupDependencies } from '../plugin'; - -export class DrilldownService { - bootstrap(core: CoreSetup, { uiActions }: DrilldownsSetupDependencies) { - const overlays = async () => (await core.getStartServices())[0].overlays; - - const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ overlays }); - uiActions.registerAction(actionFlyoutCreateDrilldown); - // uiActions.attachAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown); - - const actionFlyoutEditDrilldown = new FlyoutEditDrilldownAction({ overlays }); - uiActions.registerAction(actionFlyoutEditDrilldown); - // uiActions.attachAction(CONTEXT_MENU_TRIGGER, actionFlyoutEditDrilldown); - } - - /** - * Convenience method to register a drilldown. (It should set-up all the - * necessary triggers and actions.) - */ - registerDrilldown = (): void => { - throw new Error('not implemented'); - }; -} diff --git a/x-pack/plugins/drilldowns/public/services/drilldown_service.ts b/x-pack/plugins/drilldowns/public/services/drilldown_service.ts new file mode 100644 index 0000000000000..bfbe514d46095 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/services/drilldown_service.ts @@ -0,0 +1,79 @@ +/* + * 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 { CoreSetup } from 'src/core/public'; +import { AdvancedUiActionsSetup } from '../../../advanced_ui_actions/public'; +import { DrilldownDefinition, DrilldownFactoryContext } from '../types'; +import { UiActionsActionFactoryDefinition as ActionFactoryDefinition } from '../../../../../src/plugins/ui_actions/public'; + +export interface DrilldownServiceSetupDeps { + advancedUiActions: AdvancedUiActionsSetup; +} + +export interface DrilldownServiceSetupContract { + /** + * Convenience method to register a drilldown. + */ + registerDrilldown: < + Config extends object = object, + CreationContext extends object = object, + ExecutionContext extends object = object + >( + drilldown: DrilldownDefinition + ) => void; +} + +export class DrilldownService { + setup( + core: CoreSetup, + { advancedUiActions }: DrilldownServiceSetupDeps + ): DrilldownServiceSetupContract { + const registerDrilldown = < + Config extends object = object, + CreationContext extends object = object, + ExecutionContext extends object = object + >({ + id: factoryId, + CollectConfig, + createConfig, + isConfigValid, + getDisplayName, + euiIcon, + execute, + }: DrilldownDefinition) => { + const actionFactory: ActionFactoryDefinition< + Config, + DrilldownFactoryContext, + ExecutionContext + > = { + id: factoryId, + CollectConfig, + createConfig, + isConfigValid, + getDisplayName, + getIconType: () => euiIcon, + isCompatible: async () => true, + create: serializedAction => ({ + id: '', + type: factoryId, + getIconType: () => euiIcon, + getDisplayName: () => serializedAction.name, + execute: async context => await execute(serializedAction.config, context), + }), + } as ActionFactoryDefinition< + Config, + DrilldownFactoryContext, + ExecutionContext + >; + + advancedUiActions.registerActionFactory(actionFactory); + }; + + return { + registerDrilldown, + }; + } +} diff --git a/x-pack/plugins/drilldowns/public/service/index.ts b/x-pack/plugins/drilldowns/public/services/index.ts similarity index 100% rename from x-pack/plugins/drilldowns/public/service/index.ts rename to x-pack/plugins/drilldowns/public/services/index.ts diff --git a/x-pack/plugins/drilldowns/public/types.ts b/x-pack/plugins/drilldowns/public/types.ts new file mode 100644 index 0000000000000..a8232887f9ca6 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/types.ts @@ -0,0 +1,120 @@ +/* + * 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 { AdvancedUiActionsActionFactoryDefinition as ActionFactoryDefinition } from '../../advanced_ui_actions/public'; + +/** + * This is a convenience interface to register a drilldown. Drilldown has + * ability to collect configuration from user. Once drilldown is executed it + * receives the collected information together with the context of the + * user's interaction. + * + * `Config` is a serializable object containing the configuration that the + * drilldown is able to collect using UI. + * + * `PlaceContext` is an object that the app that opens drilldown management + * flyout provides to the React component, specifying the contextual information + * about that app. For example, on Dashboard app this context contains + * information about the current embeddable and dashboard. + * + * `ExecutionContext` is an object created in response to user's interaction + * and provided to the `execute` function of the drilldown. This object contains + * information about the action user performed. + */ +export interface DrilldownDefinition< + Config extends object = object, + PlaceContext extends object = object, + ExecutionContext extends object = object +> { + /** + * Globally unique identifier for this drilldown. + */ + id: string; + + /** + * Function that returns default config for this drilldown. + */ + createConfig: ActionFactoryDefinition< + Config, + DrilldownFactoryContext, + ExecutionContext + >['createConfig']; + + /** + * `UiComponent` that collections config for this drilldown. You can create + * a React component and transform it `UiComponent` using `uiToReactComponent` + * helper from `kibana_utils` plugin. + * + * ```tsx + * import React from 'react'; + * import { uiToReactComponent } from 'src/plugins/kibana_utils'; + * import { UiActionsCollectConfigProps as CollectConfigProps } from 'src/plugins/ui_actions/public'; + * + * type Props = CollectConfigProps; + * + * const ReactCollectConfig: React.FC = () => { + * return
Collecting config...'
; + * }; + * + * export const CollectConfig = uiToReactComponent(ReactCollectConfig); + * ``` + */ + CollectConfig: ActionFactoryDefinition< + Config, + DrilldownFactoryContext, + ExecutionContext + >['CollectConfig']; + + /** + * A validator function for the config object. Should always return a boolean + * given any input. + */ + isConfigValid: ActionFactoryDefinition< + Config, + DrilldownFactoryContext, + ExecutionContext + >['isConfigValid']; + + /** + * Name of EUI icon to display when showing this drilldown to user. + */ + euiIcon?: string; + + /** + * Should return an internationalized name of the drilldown, which will be + * displayed to the user. + */ + getDisplayName: () => string; + + /** + * Implements the "navigation" action of the drilldown. This happens when + * user clicks something in the UI that executes a trigger to which this + * drilldown was attached. + * + * @param config Config object that user configured this drilldown with. + * @param context Object that represents context in which the underlying + * `UIAction` of this drilldown is being executed in. + */ + execute(config: Config, context: ExecutionContext): void; +} + +/** + * Context object used when creating a drilldown. + */ +export interface DrilldownFactoryContext { + /** + * Context provided to the drilldown factory by the place where the UI is + * rendered. For example, for the "dashboard" place, this context contains + * the ID of the current dashboard, which could be used for filtering it out + * of the list. + */ + placeContext: T; + + /** + * List of triggers that user selected in the UI. + */ + triggers: string[]; +} diff --git a/x-pack/plugins/reporting/public/plugin.tsx b/x-pack/plugins/reporting/public/plugin.tsx index 08ba10ff69207..ac46d84469513 100644 --- a/x-pack/plugins/reporting/public/plugin.tsx +++ b/x-pack/plugins/reporting/public/plugin.tsx @@ -143,8 +143,7 @@ export class ReportingPublicPlugin implements Plugin { }, }); - uiActions.registerAction(action); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); share.register(csvReportingProvider({ apiClient, toasts, license$ })); share.register( diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index fe7ad863945c5..6290366e4b93f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -636,7 +636,6 @@ "embeddableApi.addPanel.noMatchingObjectsMessage": "一致するオブジェクトが見つかりませんでした。", "embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} が追加されました", "embeddableApi.addPanel.Title": "パネルの追加", - "embeddableApi.customizePanel.action.displayName": "パネルをカスタマイズ", "embeddableApi.customizePanel.modal.cancel": "キャンセル", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleFormRowLabel": "パネルタイトル", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleInputAriaLabel": "パネルのカスタムタイトルを入力してください", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e1cfa5e4ef358..37527023d7208 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -636,7 +636,6 @@ "embeddableApi.addPanel.noMatchingObjectsMessage": "未找到任何匹配对象。", "embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} 已添加", "embeddableApi.addPanel.Title": "添加面板", - "embeddableApi.customizePanel.action.displayName": "定制面板", "embeddableApi.customizePanel.modal.cancel": "取消", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleFormRowLabel": "面板标题", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleInputAriaLabel": "为面板输入定制标题", From 66b5efd0841a3881ae73ca04882a6c17e43ad830 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Tue, 24 Mar 2020 08:46:11 +0100 Subject: [PATCH 061/179] [ML] Module setup with dynamic model memory estimation (#60656) * [ML] add estimateModelMemory to the setup endpoint * [ML] wip caching cardinality checks * [ML] refactor * [ML] fix a fallback time range * [ML] fix typing issue * [ML] fields_aggs_cache.ts as part of fields_service * [ML] fix types, add comments * [ML] check for MML overrides * [ML] disable estimateModelMemory * [ML] fix typing * [ML] check for empty max mml * [ML] refactor, update types, fix jobsForModelMemoryEstimation * [ML] fix override lookup * [ML] resolve nit comments * [ML] init jobsForModelMemoryEstimation --- x-pack/plugins/ml/common/types/modules.ts | 10 +- .../jobs/new_job/recognize/page.tsx | 1 + .../services/ml_api_service/index.ts | 3 + .../calculate_model_memory_limit.ts | 160 ++++++------- .../models/data_recognizer/data_recognizer.ts | 215 +++++++++++++----- .../fields_service/fields_aggs_cache.ts | 66 ++++++ .../models/fields_service/fields_service.ts | 128 +++++++---- x-pack/plugins/ml/server/routes/modules.ts | 40 ++-- .../ml/server/routes/schemas/modules.ts | 5 + .../shared_services/providers/modules.ts | 9 +- 10 files changed, 443 insertions(+), 194 deletions(-) create mode 100644 x-pack/plugins/ml/server/models/fields_service/fields_aggs_cache.ts diff --git a/x-pack/plugins/ml/common/types/modules.ts b/x-pack/plugins/ml/common/types/modules.ts index e61ff9972d601..b476762f6efca 100644 --- a/x-pack/plugins/ml/common/types/modules.ts +++ b/x-pack/plugins/ml/common/types/modules.ts @@ -90,8 +90,14 @@ export interface DataRecognizerConfigResponse { }; } -export type GeneralOverride = any; - export type JobOverride = Partial; +export type GeneralJobsOverride = Omit; +export type JobSpecificOverride = JobOverride & { job_id: Job['job_id'] }; + +export function isGeneralJobOverride(override: JobOverride): override is GeneralJobsOverride { + return override.job_id === undefined; +} + +export type GeneralDatafeedsOverride = Partial>; export type DatafeedOverride = Partial; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/page.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/page.tsx index 50c35ec426acb..9b76b9be9bf45 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/page.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/page.tsx @@ -172,6 +172,7 @@ export const Page: FC = ({ moduleId, existingGroupIds }) => { startDatafeed: startDatafeedAfterSave, ...(jobOverridesPayload !== null ? { jobOverrides: jobOverridesPayload } : {}), ...resultTimeRange, + estimateModelMemory: false, }); const { datafeeds: datafeedsResponse, jobs: jobsResponse, kibana: kibanaResponse } = response; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index cd4a97bd10ed4..df59678452e2f 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -367,6 +367,7 @@ export const ml = { start, end, jobOverrides, + estimateModelMemory, }: { moduleId: string; prefix?: string; @@ -378,6 +379,7 @@ export const ml = { start?: number; end?: number; jobOverrides?: Array>; + estimateModelMemory?: boolean; }) { const body = JSON.stringify({ prefix, @@ -389,6 +391,7 @@ export const ml = { start, end, jobOverrides, + estimateModelMemory, }); return http({ diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts index c97bbe07fffda..cd61dd9eddcdd 100644 --- a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts +++ b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts @@ -6,6 +6,7 @@ import numeral from '@elastic/numeral'; import { APICaller } from 'kibana/server'; +import { MLCATEGORY } from '../../../common/constants/field_types'; import { AnalysisConfig } from '../../../common/types/anomaly_detection_jobs'; import { fieldsServiceProvider } from '../fields_service'; @@ -34,92 +35,96 @@ export interface ModelMemoryEstimate { /** * Retrieves overall and max bucket cardinalities. */ -async function getCardinalities( - callAsCurrentUser: APICaller, - analysisConfig: AnalysisConfig, - indexPattern: string, - query: any, - timeFieldName: string, - earliestMs: number, - latestMs: number -): Promise<{ - overallCardinality: { [key: string]: number }; - maxBucketCardinality: { [key: string]: number }; -}> { - /** - * Fields not involved in cardinality check - */ - const excludedKeywords = new Set( - /** - * The keyword which is used to mean the output of categorization, - * so it will have cardinality zero in the actual input data. - */ - 'mlcategory' - ); - +const cardinalityCheckProvider = (callAsCurrentUser: APICaller) => { const fieldsService = fieldsServiceProvider(callAsCurrentUser); - const { detectors, influencers, bucket_span: bucketSpan } = analysisConfig; - - let overallCardinality = {}; - let maxBucketCardinality = {}; - const overallCardinalityFields: Set = detectors.reduce( - ( - acc, - { - by_field_name: byFieldName, - partition_field_name: partitionFieldName, - over_field_name: overFieldName, - } - ) => { - [byFieldName, partitionFieldName, overFieldName] - .filter(field => field !== undefined && field !== '' && !excludedKeywords.has(field)) - .forEach(key => { - acc.add(key as string); - }); - return acc; - }, - new Set() - ); - - const maxBucketFieldCardinalities: string[] = influencers.filter( - influencerField => - typeof influencerField === 'string' && - !excludedKeywords.has(influencerField) && - !!influencerField && - !overallCardinalityFields.has(influencerField) - ) as string[]; - - if (overallCardinalityFields.size > 0) { - overallCardinality = await fieldsService.getCardinalityOfFields( - indexPattern, - [...overallCardinalityFields], - query, - timeFieldName, - earliestMs, - latestMs + return async ( + analysisConfig: AnalysisConfig, + indexPattern: string, + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number + ): Promise<{ + overallCardinality: { [key: string]: number }; + maxBucketCardinality: { [key: string]: number }; + }> => { + /** + * Fields not involved in cardinality check + */ + const excludedKeywords = new Set( + /** + * The keyword which is used to mean the output of categorization, + * so it will have cardinality zero in the actual input data. + */ + MLCATEGORY ); - } - if (maxBucketFieldCardinalities.length > 0) { - maxBucketCardinality = await fieldsService.getMaxBucketCardinalities( - indexPattern, - maxBucketFieldCardinalities, - query, - timeFieldName, - earliestMs, - latestMs, - bucketSpan + const { detectors, influencers, bucket_span: bucketSpan } = analysisConfig; + + let overallCardinality = {}; + let maxBucketCardinality = {}; + + // Get fields required for the model memory estimation + const overallCardinalityFields: Set = detectors.reduce( + ( + acc, + { + by_field_name: byFieldName, + partition_field_name: partitionFieldName, + over_field_name: overFieldName, + } + ) => { + [byFieldName, partitionFieldName, overFieldName] + .filter(field => field !== undefined && field !== '' && !excludedKeywords.has(field)) + .forEach(key => { + acc.add(key as string); + }); + return acc; + }, + new Set() ); - } - return { - overallCardinality, - maxBucketCardinality, + const maxBucketFieldCardinalities: string[] = influencers.filter( + influencerField => + !!influencerField && + !excludedKeywords.has(influencerField) && + !overallCardinalityFields.has(influencerField) + ) as string[]; + + if (overallCardinalityFields.size > 0) { + overallCardinality = await fieldsService.getCardinalityOfFields( + indexPattern, + [...overallCardinalityFields], + query, + timeFieldName, + earliestMs, + latestMs + ); + } + + if (maxBucketFieldCardinalities.length > 0) { + maxBucketCardinality = await fieldsService.getMaxBucketCardinalities( + indexPattern, + maxBucketFieldCardinalities, + query, + timeFieldName, + earliestMs, + latestMs, + bucketSpan + ); + } + + return { + overallCardinality, + maxBucketCardinality, + }; }; -} +}; export function calculateModelMemoryLimitProvider(callAsCurrentUser: APICaller) { + const getCardinalities = cardinalityCheckProvider(callAsCurrentUser); + /** * Retrieves an estimated size of the model memory limit used in the job config * based on the cardinality of the fields being used to split the data @@ -145,7 +150,6 @@ export function calculateModelMemoryLimitProvider(callAsCurrentUser: APICaller) } const { overallCardinality, maxBucketCardinality } = await getCardinalities( - callAsCurrentUser, analysisConfig, indexPattern, query, diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts index a54c2f22a7951..824f9cc57982c 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts @@ -7,10 +7,12 @@ import fs from 'fs'; import Boom from 'boom'; import numeral from '@elastic/numeral'; -import { CallAPIOptions, APICaller, SavedObjectsClientContract } from 'kibana/server'; +import { APICaller, SavedObjectsClientContract } from 'kibana/server'; +import moment from 'moment'; import { IndexPatternAttributes } from 'src/plugins/data/server'; import { merge } from 'lodash'; -import { CombinedJobWithStats } from '../../../common/types/anomaly_detection_jobs'; +import { AnalysisLimits, CombinedJobWithStats } from '../../../common/types/anomaly_detection_jobs'; +import { MlInfoResponse } from '../../../common/types/ml_server_info'; import { KibanaObjects, ModuleDataFeed, @@ -18,14 +20,19 @@ import { Module, JobOverride, DatafeedOverride, - GeneralOverride, + GeneralJobsOverride, DatafeedResponse, JobResponse, KibanaObjectResponse, DataRecognizerConfigResponse, + GeneralDatafeedsOverride, + JobSpecificOverride, + isGeneralJobOverride, } from '../../../common/types/modules'; import { getLatestDataOrBucketTimestamp, prefixDatafeedId } from '../../../common/util/job_utils'; import { mlLog } from '../../client/log'; +import { calculateModelMemoryLimitProvider } from '../calculate_model_memory_limit'; +import { fieldsServiceProvider } from '../fields_service'; import { jobServiceProvider } from '../job_service'; import { resultsServiceProvider } from '../results_service'; @@ -107,18 +114,15 @@ export class DataRecognizer { modulesDir = `${__dirname}/modules`; indexPatternName: string = ''; indexPatternId: string | undefined = undefined; - savedObjectsClient: SavedObjectsClientContract; + /** + * List of the module jobs that require model memory estimation + */ + jobsForModelMemoryEstimation: ModuleJob[] = []; - callAsCurrentUser: ( - endpoint: string, - clientParams?: Record, - options?: CallAPIOptions - ) => Promise; - - constructor(callAsCurrentUser: APICaller, savedObjectsClient: SavedObjectsClientContract) { - this.callAsCurrentUser = callAsCurrentUser; - this.savedObjectsClient = savedObjectsClient; - } + constructor( + private callAsCurrentUser: APICaller, + private savedObjectsClient: SavedObjectsClientContract + ) {} // list all directories under the given directory async listDirs(dirName: string): Promise { @@ -367,16 +371,17 @@ export class DataRecognizer { // if any of the savedObjects already exist, they will not be overwritten. async setupModuleItems( moduleId: string, - jobPrefix: string, - groups: string[], - indexPatternName: string, - query: any, - useDedicatedIndex: boolean, - startDatafeed: boolean, - start: number, - end: number, - jobOverrides: JobOverride[], - datafeedOverrides: DatafeedOverride[] + jobPrefix?: string, + groups?: string[], + indexPatternName?: string, + query?: any, + useDedicatedIndex?: boolean, + startDatafeed?: boolean, + start?: number, + end?: number, + jobOverrides?: JobOverride | JobOverride[], + datafeedOverrides?: DatafeedOverride | DatafeedOverride[], + estimateModelMemory?: boolean ) { // load the config from disk const moduleConfig = await this.getModule(moduleId, jobPrefix); @@ -418,11 +423,13 @@ export class DataRecognizer { savedObjects: [] as KibanaObjectResponse[], }; + this.jobsForModelMemoryEstimation = moduleConfig.jobs; + this.applyJobConfigOverrides(moduleConfig, jobOverrides, jobPrefix); this.applyDatafeedConfigOverrides(moduleConfig, datafeedOverrides, jobPrefix); this.updateDatafeedIndices(moduleConfig); this.updateJobUrlIndexPatterns(moduleConfig); - await this.updateModelMemoryLimits(moduleConfig); + await this.updateModelMemoryLimits(moduleConfig, estimateModelMemory, start, end); // create the jobs if (moduleConfig.jobs && moduleConfig.jobs.length) { @@ -689,8 +696,8 @@ export class DataRecognizer { async startDatafeeds( datafeeds: ModuleDataFeed[], - start: number, - end: number + start?: number, + end?: number ): Promise<{ [key: string]: DatafeedResponse }> { const results = {} as { [key: string]: DatafeedResponse }; for (const datafeed of datafeeds) { @@ -933,28 +940,117 @@ export class DataRecognizer { } } - // ensure the model memory limit for each job is not greater than - // the max model memory setting for the cluster - async updateModelMemoryLimits(moduleConfig: Module) { - const { limits } = await this.callAsCurrentUser('ml.info'); + /** + * Provides a time range of the last 3 months of data + */ + async getFallbackTimeRange( + timeField: string, + query?: any + ): Promise<{ start: number; end: number }> { + const fieldsService = fieldsServiceProvider(this.callAsCurrentUser); + + const timeFieldRange = await fieldsService.getTimeFieldRange( + this.indexPatternName, + timeField, + query + ); + + return { + start: timeFieldRange.end.epoch - moment.duration(3, 'months').asMilliseconds(), + end: timeFieldRange.end.epoch, + }; + } + + /** + * Ensure the model memory limit for each job is not greater than + * the max model memory setting for the cluster + */ + async updateModelMemoryLimits( + moduleConfig: Module, + estimateMML: boolean = false, + start?: number, + end?: number + ) { + if (!Array.isArray(moduleConfig.jobs)) { + return; + } + + if (estimateMML && this.jobsForModelMemoryEstimation.length > 0) { + const calculateModelMemoryLimit = calculateModelMemoryLimitProvider(this.callAsCurrentUser); + const query = moduleConfig.query ?? null; + + // Checks if all jobs in the module have the same time field configured + const isSameTimeFields = this.jobsForModelMemoryEstimation.every( + job => + job.config.data_description.time_field === + this.jobsForModelMemoryEstimation[0].config.data_description.time_field + ); + + if (isSameTimeFields && (start === undefined || end === undefined)) { + // In case of time range is not provided and the time field is the same + // set the fallback range for all jobs + const { start: fallbackStart, end: fallbackEnd } = await this.getFallbackTimeRange( + this.jobsForModelMemoryEstimation[0].config.data_description.time_field, + query + ); + start = fallbackStart; + end = fallbackEnd; + } + + for (const job of this.jobsForModelMemoryEstimation) { + let earliestMs = start; + let latestMs = end; + if (earliestMs === undefined || latestMs === undefined) { + const timeFieldRange = await this.getFallbackTimeRange( + job.config.data_description.time_field, + query + ); + earliestMs = timeFieldRange.start; + latestMs = timeFieldRange.end; + } + + const { modelMemoryLimit } = await calculateModelMemoryLimit( + job.config.analysis_config, + this.indexPatternName, + query, + job.config.data_description.time_field, + earliestMs, + latestMs + ); + + if (!job.config.analysis_limits) { + job.config.analysis_limits = {} as AnalysisLimits; + } + + job.config.analysis_limits.model_memory_limit = modelMemoryLimit; + } + } + + const { limits } = await this.callAsCurrentUser('ml.info'); const maxMml = limits.max_model_memory_limit; - if (maxMml !== undefined) { - // @ts-ignore - const maxBytes: number = numeral(maxMml.toUpperCase()).value(); - - if (Array.isArray(moduleConfig.jobs)) { - moduleConfig.jobs.forEach(job => { - const mml = job.config?.analysis_limits?.model_memory_limit; - if (mml !== undefined) { - // @ts-ignore - const mmlBytes: number = numeral(mml.toUpperCase()).value(); - if (mmlBytes > maxBytes) { - // if the job's mml is over the max, - // so set the jobs mml to be the max - job.config.analysis_limits!.model_memory_limit = maxMml; - } + + if (!maxMml) { + return; + } + + // @ts-ignore + const maxBytes: number = numeral(maxMml.toUpperCase()).value(); + + for (const job of moduleConfig.jobs) { + const mml = job.config?.analysis_limits?.model_memory_limit; + if (mml !== undefined) { + // @ts-ignore + const mmlBytes: number = numeral(mml.toUpperCase()).value(); + if (mmlBytes > maxBytes) { + // if the job's mml is over the max, + // so set the jobs mml to be the max + + if (!job.config.analysis_limits) { + job.config.analysis_limits = {} as AnalysisLimits; } - }); + + job.config.analysis_limits.model_memory_limit = maxMml; + } } } } @@ -975,7 +1071,11 @@ export class DataRecognizer { return false; } - applyJobConfigOverrides(moduleConfig: Module, jobOverrides: JobOverride[], jobPrefix = '') { + applyJobConfigOverrides( + moduleConfig: Module, + jobOverrides?: JobOverride | JobOverride[], + jobPrefix = '' + ) { if (jobOverrides === undefined || jobOverrides === null) { return; } @@ -993,17 +1093,26 @@ export class DataRecognizer { // separate all the overrides. // the overrides which don't contain a job id will be applied to all jobs in the module - const generalOverrides: GeneralOverride[] = []; - const jobSpecificOverrides: JobOverride[] = []; + const generalOverrides: GeneralJobsOverride[] = []; + const jobSpecificOverrides: JobSpecificOverride[] = []; overrides.forEach(override => { - if (override.job_id === undefined) { + if (isGeneralJobOverride(override)) { generalOverrides.push(override); } else { jobSpecificOverrides.push(override); } }); + if (generalOverrides.some(override => !!override.analysis_limits?.model_memory_limit)) { + this.jobsForModelMemoryEstimation = []; + } else { + this.jobsForModelMemoryEstimation = moduleConfig.jobs.filter(job => { + const override = jobSpecificOverrides.find(o => `${jobPrefix}${o.job_id}` === job.id); + return override?.analysis_limits?.model_memory_limit === undefined; + }); + } + function processArrayValues(source: any, update: any) { if (typeof source !== 'object' || typeof update !== 'object') { return; @@ -1052,7 +1161,7 @@ export class DataRecognizer { applyDatafeedConfigOverrides( moduleConfig: Module, - datafeedOverrides: DatafeedOverride | DatafeedOverride[], + datafeedOverrides?: DatafeedOverride | DatafeedOverride[], jobPrefix = '' ) { if (datafeedOverrides !== undefined && datafeedOverrides !== null) { @@ -1069,7 +1178,7 @@ export class DataRecognizer { // separate all the overrides. // the overrides which don't contain a datafeed id or a job id will be applied to all jobs in the module - const generalOverrides: GeneralOverride[] = []; + const generalOverrides: GeneralDatafeedsOverride[] = []; const datafeedSpecificOverrides: DatafeedOverride[] = []; overrides.forEach(o => { if (o.datafeed_id === undefined && o.job_id === undefined) { diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_aggs_cache.ts b/x-pack/plugins/ml/server/models/fields_service/fields_aggs_cache.ts new file mode 100644 index 0000000000000..cdaefe6fdeed7 --- /dev/null +++ b/x-pack/plugins/ml/server/models/fields_service/fields_aggs_cache.ts @@ -0,0 +1,66 @@ +/* + * 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 { pick } from 'lodash'; + +/** + * Cached aggregation types + */ +type AggType = 'overallCardinality' | 'maxBucketCardinality'; + +type CacheStorage = { [key in AggType]: { [field: string]: number } }; + +/** + * Caches cardinality fields values to avoid + * unnecessary aggregations on elasticsearch + */ +export const initCardinalityFieldsCache = () => { + const cardinalityCache = new Map(); + + return { + /** + * Gets requested values from cache + */ + getValues( + indexPatternName: string | string[], + timeField: string, + earliestMs: number, + latestMs: number, + aggType: AggType, + fieldNames: string[] + ): CacheStorage[AggType] | null { + const cacheKey = indexPatternName + timeField + earliestMs + latestMs; + const cached = cardinalityCache.get(cacheKey); + if (!cached) { + return null; + } + return pick(cached[aggType], fieldNames); + }, + /** + * Extends cache with provided values + */ + updateValues( + indexPatternName: string | string[], + timeField: string, + earliestMs: number, + latestMs: number, + update: Partial + ): void { + const cacheKey = indexPatternName + timeField + earliestMs + latestMs; + const cachedValues = cardinalityCache.get(cacheKey); + if (cachedValues === undefined) { + cardinalityCache.set(cacheKey, { + overallCardinality: update.overallCardinality ?? {}, + maxBucketCardinality: update.maxBucketCardinality ?? {}, + }); + return; + } + + Object.assign(cachedValues.overallCardinality, update.overallCardinality); + Object.assign(cachedValues.maxBucketCardinality, update.maxBucketCardinality); + }, + }; +}; diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_service.ts b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts index d16984abc5d2a..567c5d2afb7de 100644 --- a/x-pack/plugins/ml/server/models/fields_service/fields_service.ts +++ b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts @@ -7,12 +7,15 @@ import Boom from 'boom'; import { APICaller } from 'kibana/server'; import { parseInterval } from '../../../common/util/parse_interval'; +import { initCardinalityFieldsCache } from './fields_aggs_cache'; /** * Service for carrying out queries to obtain data * specific to fields in Elasticsearch indices. */ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { + const fieldsAggsCache = initCardinalityFieldsCache(); + /** * Gets aggregatable fields. */ @@ -58,6 +61,23 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { return {}; } + const cachedValues = + fieldsAggsCache.getValues( + index, + timeFieldName, + earliestMs, + latestMs, + 'overallCardinality', + fieldNames + ) ?? {}; + + // No need to perform aggregation over the cached fields + const fieldsToAgg = aggregatableFields.filter(field => !cachedValues.hasOwnProperty(field)); + + if (fieldsToAgg.length === 0) { + return cachedValues; + } + // Build the criteria to use in the bool filter part of the request. // Add criteria for the time range and the datafeed config query. const mustCriteria = [ @@ -76,7 +96,7 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { mustCriteria.push(query); } - const aggs = aggregatableFields.reduce((obj, field) => { + const aggs = fieldsToAgg.reduce((obj, field) => { obj[field] = { cardinality: { field } }; return obj; }, {} as { [field: string]: { cardinality: { field: string } } }); @@ -105,53 +125,63 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { return {}; } - return aggregatableFields.reduce((obj, field) => { + const aggResult = fieldsToAgg.reduce((obj, field) => { obj[field] = (aggregations[field] || { value: 0 }).value; return obj; }, {} as { [field: string]: number }); + + fieldsAggsCache.updateValues(index, timeFieldName, earliestMs, latestMs, { + overallCardinality: aggResult, + }); + + return { + ...cachedValues, + ...aggResult, + }; } - function getTimeFieldRange( + /** + * Gets time boundaries of the index data based on the provided time field. + */ + async function getTimeFieldRange( index: string[] | string, timeFieldName: string, query: any - ): Promise { - return new Promise((resolve, reject) => { - const obj = { success: true, start: { epoch: 0, string: '' }, end: { epoch: 0, string: '' } }; - - callAsCurrentUser('search', { - index, - size: 0, - body: { - query, - aggs: { - earliest: { - min: { - field: timeFieldName, - }, + ): Promise<{ + success: boolean; + start: { epoch: number; string: string }; + end: { epoch: number; string: string }; + }> { + const obj = { success: true, start: { epoch: 0, string: '' }, end: { epoch: 0, string: '' } }; + + const resp = await callAsCurrentUser('search', { + index, + size: 0, + body: { + ...(query ? { query } : {}), + aggs: { + earliest: { + min: { + field: timeFieldName, }, - latest: { - max: { - field: timeFieldName, - }, + }, + latest: { + max: { + field: timeFieldName, }, }, }, - }) - .then(resp => { - if (resp.aggregations && resp.aggregations.earliest && resp.aggregations.latest) { - obj.start.epoch = resp.aggregations.earliest.value; - obj.start.string = resp.aggregations.earliest.value_as_string; - - obj.end.epoch = resp.aggregations.latest.value; - obj.end.string = resp.aggregations.latest.value_as_string; - } - resolve(obj); - }) - .catch(resp => { - reject(resp); - }); + }, }); + + if (resp.aggregations && resp.aggregations.earliest && resp.aggregations.latest) { + obj.start.epoch = resp.aggregations.earliest.value; + obj.start.string = resp.aggregations.earliest.value_as_string; + + obj.end.epoch = resp.aggregations.latest.value; + obj.end.string = resp.aggregations.latest.value_as_string; + } + return obj; } /** @@ -213,6 +243,23 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { return {}; } + const cachedValues = + fieldsAggsCache.getValues( + index, + timeFieldName, + earliestMs, + latestMs, + 'maxBucketCardinality', + fieldNames + ) ?? {}; + + // No need to perform aggregation over the cached fields + const fieldsToAgg = aggregatableFields.filter(field => !cachedValues.hasOwnProperty(field)); + + if (fieldsToAgg.length === 0) { + return cachedValues; + } + const { start, end } = getSafeTimeRange(earliestMs, latestMs, interval); const mustCriteria = [ @@ -238,7 +285,7 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { const getSafeAggName = (field: string) => field.replace(/\W/g, ''); const getMaxBucketAggKey = (field: string) => `max_bucket_${field}`; - const fieldsCardinalityAggs = aggregatableFields.reduce((obj, field) => { + const fieldsCardinalityAggs = fieldsToAgg.reduce((obj, field) => { obj[getSafeAggName(field)] = { cardinality: { field } }; return obj; }, {} as { [field: string]: { cardinality: { field: string } } }); @@ -279,13 +326,18 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { )?.aggregations; if (!aggregations) { - return {}; + return cachedValues; } - return aggregatableFields.reduce((obj, field) => { + const aggResult = fieldsToAgg.reduce((obj, field) => { obj[field] = (aggregations[getMaxBucketAggKey(field)] || { value: 0 }).value ?? 0; return obj; }, {} as { [field: string]: number }); + + return { + ...cachedValues, + ...aggResult, + }; } return { diff --git a/x-pack/plugins/ml/server/routes/modules.ts b/x-pack/plugins/ml/server/routes/modules.ts index 685119672a983..358cd0ac2871c 100644 --- a/x-pack/plugins/ml/server/routes/modules.ts +++ b/x-pack/plugins/ml/server/routes/modules.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema } from '@kbn/config-schema'; +import { schema, TypeOf } from '@kbn/config-schema'; import { RequestHandlerContext } from 'kibana/server'; import { DatafeedOverride, JobOverride } from '../../common/types/modules'; @@ -36,16 +36,17 @@ function getModule(context: RequestHandlerContext, moduleId: string) { function saveModuleItems( context: RequestHandlerContext, moduleId: string, - prefix: string, - groups: string[], - indexPatternName: string, - query: any, - useDedicatedIndex: boolean, - startDatafeed: boolean, - start: number, - end: number, - jobOverrides: JobOverride[], - datafeedOverrides: DatafeedOverride[] + prefix?: string, + groups?: string[], + indexPatternName?: string, + query?: any, + useDedicatedIndex?: boolean, + startDatafeed?: boolean, + start?: number, + end?: number, + jobOverrides?: JobOverride | JobOverride[], + datafeedOverrides?: DatafeedOverride | DatafeedOverride[], + estimateModelMemory?: boolean ) { const dr = new DataRecognizer( context.ml!.mlClient.callAsCurrentUser, @@ -62,7 +63,8 @@ function saveModuleItems( start, end, jobOverrides, - datafeedOverrides + datafeedOverrides, + estimateModelMemory ); } @@ -156,9 +158,7 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { { path: '/api/ml/modules/setup/{moduleId}', validate: { - params: schema.object({ - ...getModuleIdParamSchema(), - }), + params: schema.object(getModuleIdParamSchema()), body: setupModuleBodySchema, }, }, @@ -177,7 +177,8 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { end, jobOverrides, datafeedOverrides, - } = request.body; + estimateModelMemory, + } = request.body as TypeOf; const result = await saveModuleItems( context, @@ -191,7 +192,8 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { start, end, jobOverrides, - datafeedOverrides + datafeedOverrides, + estimateModelMemory ); return response.ok({ body: result }); @@ -214,9 +216,7 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { { path: '/api/ml/modules/jobs_exist/{moduleId}', validate: { - params: schema.object({ - ...getModuleIdParamSchema(), - }), + params: schema.object(getModuleIdParamSchema()), }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { diff --git a/x-pack/plugins/ml/server/routes/schemas/modules.ts b/x-pack/plugins/ml/server/routes/schemas/modules.ts index 46b7e53c22a05..98e3d80f0ff84 100644 --- a/x-pack/plugins/ml/server/routes/schemas/modules.ts +++ b/x-pack/plugins/ml/server/routes/schemas/modules.ts @@ -17,6 +17,11 @@ export const setupModuleBodySchema = schema.object({ end: schema.maybe(schema.number()), jobOverrides: schema.maybe(schema.any()), datafeedOverrides: schema.maybe(schema.any()), + /** + * Indicates whether an estimate of the model memory limit + * should be made by checking the cardinality of fields in the job configurations. + */ + estimateModelMemory: schema.maybe(schema.boolean()), }); export const getModuleIdParamSchema = (optional = false) => { diff --git a/x-pack/plugins/ml/server/shared_services/providers/modules.ts b/x-pack/plugins/ml/server/shared_services/providers/modules.ts index ffc977917ae46..ec876273c2c33 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/modules.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/modules.ts @@ -32,7 +32,8 @@ export interface ModulesProvider { start: number, end: number, jobOverrides: JobOverride[], - datafeedOverrides: DatafeedOverride[] + datafeedOverrides: DatafeedOverride[], + estimateModelMemory?: boolean ): Promise; }; } @@ -65,7 +66,8 @@ export function getModulesProvider(isFullLicense: LicenseCheck): ModulesProvider start: number, end: number, jobOverrides: JobOverride[], - datafeedOverrides: DatafeedOverride[] + datafeedOverrides: DatafeedOverride[], + estimateModelMemory?: boolean ) { const dr = dataRecognizerFactory(callAsCurrentUser, savedObjectsClient); return dr.setupModuleItems( @@ -79,7 +81,8 @@ export function getModulesProvider(isFullLicense: LicenseCheck): ModulesProvider start, end, jobOverrides, - datafeedOverrides + datafeedOverrides, + estimateModelMemory ); }, }; From 8f55a8edc79873bae3a0eb8b61d43f32adde94b3 Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Tue, 24 Mar 2020 11:19:22 +0300 Subject: [PATCH 062/179] Migrated styles for "share" plugin to new platform (#59981) Co-authored-by: Elastic Machine --- src/legacy/ui/public/_index.scss | 1 - .../share => plugins/share/public/components}/_index.scss | 0 .../share/public/components}/_share_context_menu.scss | 0 src/plugins/share/public/index.scss | 1 + src/plugins/share/public/plugin.ts | 2 ++ 5 files changed, 3 insertions(+), 1 deletion(-) rename src/{legacy/ui/public/share => plugins/share/public/components}/_index.scss (100%) rename src/{legacy/ui/public/share => plugins/share/public/components}/_share_context_menu.scss (100%) create mode 100644 src/plugins/share/public/index.scss diff --git a/src/legacy/ui/public/_index.scss b/src/legacy/ui/public/_index.scss index 3c3067776a161..87006d9347de4 100644 --- a/src/legacy/ui/public/_index.scss +++ b/src/legacy/ui/public/_index.scss @@ -15,7 +15,6 @@ @import './error_url_overflow/index'; @import './exit_full_screen/index'; @import './field_editor/index'; -@import './share/index'; @import './style_compile/index'; @import '../../../plugins/management/public/components/index'; diff --git a/src/legacy/ui/public/share/_index.scss b/src/plugins/share/public/components/_index.scss similarity index 100% rename from src/legacy/ui/public/share/_index.scss rename to src/plugins/share/public/components/_index.scss diff --git a/src/legacy/ui/public/share/_share_context_menu.scss b/src/plugins/share/public/components/_share_context_menu.scss similarity index 100% rename from src/legacy/ui/public/share/_share_context_menu.scss rename to src/plugins/share/public/components/_share_context_menu.scss diff --git a/src/plugins/share/public/index.scss b/src/plugins/share/public/index.scss new file mode 100644 index 0000000000000..0271fbb8e9026 --- /dev/null +++ b/src/plugins/share/public/index.scss @@ -0,0 +1 @@ +@import './components/index' \ No newline at end of file diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index 5b638174b4dfb..d02f51af42905 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -17,6 +17,8 @@ * under the License. */ +import './index.scss'; + import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { ShareMenuManager, ShareMenuManagerStart } from './services'; import { ShareMenuRegistry, ShareMenuRegistrySetup } from './services'; From c26493d56c0b93c9baa7a30a0e1cb65b86f63636 Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Tue, 24 Mar 2020 11:19:42 +0300 Subject: [PATCH 063/179] [App Arch] migrate legacy CSS to new platform (core_plugins/kibana_react) (#59882) * Migrate markdown styles to the new platform * Removed unused import * Update index.ts * Removed not need layer * Fixed paths Co-authored-by: Elastic Machine --- .../np_ready/angular/context/query/actions.js | 2 +- src/legacy/core_plugins/kibana_react/index.ts | 41 ------------------- .../core_plugins/kibana_react/package.json | 4 -- .../kibana_react/public/index.scss | 3 -- .../core_plugins/kibana_react/public/index.ts | 20 --------- .../public/markdown_vis_controller.tsx | 2 +- .../components/vis_types/markdown/vis.js | 2 +- .../components/vis_types/timeseries/vis.js | 2 +- .../public/markdown/_markdown.scss | 0 .../kibana_react/public/markdown/index.scss} | 0 .../public/markdown/{index.ts => index.tsx} | 0 .../kibana_react/public/markdown/markdown.tsx | 1 + .../renderers/markdown/index.js | 2 +- .../license/logstash_license_service.js | 2 +- 14 files changed, 7 insertions(+), 74 deletions(-) delete mode 100644 src/legacy/core_plugins/kibana_react/index.ts delete mode 100644 src/legacy/core_plugins/kibana_react/package.json delete mode 100644 src/legacy/core_plugins/kibana_react/public/index.scss delete mode 100644 src/legacy/core_plugins/kibana_react/public/index.ts rename src/{legacy/core_plugins => plugins}/kibana_react/public/markdown/_markdown.scss (100%) rename src/{legacy/core_plugins/kibana_react/public/markdown/_index.scss => plugins/kibana_react/public/markdown/index.scss} (100%) rename src/plugins/kibana_react/public/markdown/{index.ts => index.tsx} (100%) diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js index 674f40d0186e5..9efddc5275069 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js @@ -26,7 +26,7 @@ import { fetchAnchorProvider } from '../api/anchor'; import { fetchContextProvider } from '../api/context'; import { getQueryParameterActions } from '../query_parameters'; import { FAILURE_REASONS, LOADING_STATUS } from './constants'; -import { MarkdownSimple } from '../../../../../../../kibana_react/public'; +import { MarkdownSimple } from '../../../../../../../../../plugins/kibana_react/public'; export function QueryActionsProvider(Promise) { const { filterManager, indexPatterns } = getServices(); diff --git a/src/legacy/core_plugins/kibana_react/index.ts b/src/legacy/core_plugins/kibana_react/index.ts deleted file mode 100644 index f4083f3d50c34..0000000000000 --- a/src/legacy/core_plugins/kibana_react/index.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 { resolve } from 'path'; -import { Legacy } from '../../../../kibana'; - -// eslint-disable-next-line import/no-default-export -export default function DataPlugin(kibana: any) { - const config: Legacy.PluginSpecOptions = { - id: 'kibana_react', - require: [], - config: (Joi: any) => { - return Joi.object({ - enabled: Joi.boolean().default(true), - }).default(); - }, - init: (server: Legacy.Server) => ({}), - uiExports: { - injectDefaultVars: () => ({}), - styleSheetPaths: resolve(__dirname, 'public/index.scss'), - }, - }; - - return new kibana.Plugin(config); -} diff --git a/src/legacy/core_plugins/kibana_react/package.json b/src/legacy/core_plugins/kibana_react/package.json deleted file mode 100644 index 3f7cf717a1963..0000000000000 --- a/src/legacy/core_plugins/kibana_react/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "kibana_react", - "version": "kibana" -} diff --git a/src/legacy/core_plugins/kibana_react/public/index.scss b/src/legacy/core_plugins/kibana_react/public/index.scss deleted file mode 100644 index 14b4687c459e1..0000000000000 --- a/src/legacy/core_plugins/kibana_react/public/index.scss +++ /dev/null @@ -1,3 +0,0 @@ -@import 'src/legacy/ui/public/styles/styling_constants'; - -@import './markdown/index'; diff --git a/src/legacy/core_plugins/kibana_react/public/index.ts b/src/legacy/core_plugins/kibana_react/public/index.ts deleted file mode 100644 index a6a7cb72a8dee..0000000000000 --- a/src/legacy/core_plugins/kibana_react/public/index.ts +++ /dev/null @@ -1,20 +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. - */ - -export { Markdown, MarkdownSimple } from '../../../../plugins/kibana_react/public'; diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx b/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx index 4e77bb196b713..3260e9f7d8091 100644 --- a/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx +++ b/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; -import { Markdown } from '../../kibana_react/public'; +import { Markdown } from '../../../../plugins/kibana_react/public'; import { MarkdownVisParams } from './types'; interface MarkdownVisComponentProps extends MarkdownVisParams { diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js index a806339085450..d8bcf56b48cb9 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js @@ -21,7 +21,7 @@ import React from 'react'; import classNames from 'classnames'; import uuid from 'uuid'; import { get } from 'lodash'; -import { Markdown } from '../../../../../kibana_react/public'; +import { Markdown } from '../../../../../../../plugins/kibana_react/public'; import { ErrorComponent } from '../../error'; import { replaceVars } from '../../lib/replace_vars'; diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js index 356ba08ac2427..f559bc38b6c58 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js @@ -27,7 +27,7 @@ import { ScaleType } from '@elastic/charts'; import { createTickFormatter } from '../../lib/tick_formatter'; import { TimeSeries } from '../../../visualizations/views/timeseries'; -import { MarkdownSimple } from '../../../../../kibana_react/public'; +import { MarkdownSimple } from '../../../../../../../plugins/kibana_react/public'; import { replaceVars } from '../../lib/replace_vars'; import { getAxisLabelString } from '../../lib/get_axis_label_string'; import { getInterval } from '../../lib/get_interval'; diff --git a/src/legacy/core_plugins/kibana_react/public/markdown/_markdown.scss b/src/plugins/kibana_react/public/markdown/_markdown.scss similarity index 100% rename from src/legacy/core_plugins/kibana_react/public/markdown/_markdown.scss rename to src/plugins/kibana_react/public/markdown/_markdown.scss diff --git a/src/legacy/core_plugins/kibana_react/public/markdown/_index.scss b/src/plugins/kibana_react/public/markdown/index.scss similarity index 100% rename from src/legacy/core_plugins/kibana_react/public/markdown/_index.scss rename to src/plugins/kibana_react/public/markdown/index.scss diff --git a/src/plugins/kibana_react/public/markdown/index.ts b/src/plugins/kibana_react/public/markdown/index.tsx similarity index 100% rename from src/plugins/kibana_react/public/markdown/index.ts rename to src/plugins/kibana_react/public/markdown/index.tsx diff --git a/src/plugins/kibana_react/public/markdown/markdown.tsx b/src/plugins/kibana_react/public/markdown/markdown.tsx index ba81b5e111cbd..a0c2cdad78c66 100644 --- a/src/plugins/kibana_react/public/markdown/markdown.tsx +++ b/src/plugins/kibana_react/public/markdown/markdown.tsx @@ -23,6 +23,7 @@ import MarkdownIt from 'markdown-it'; import { memoize } from 'lodash'; import { getSecureRelForTarget } from '@elastic/eui'; +import './index.scss'; /** * Return a memoized markdown rendering function that use the specified * whiteListedRules and openLinksInNewTab configurations. diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js index c1bfd7c99ac41..126699534caad 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js @@ -7,7 +7,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { RendererStrings } from '../../../i18n'; -import { Markdown } from '../../../../../../../src/legacy/core_plugins/kibana_react/public'; +import { Markdown } from '../../../../../../../src/plugins/kibana_react/public'; const { markdown: strings } = RendererStrings; diff --git a/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js b/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js index 795899ff32f97..97b336ec0728b 100755 --- a/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js +++ b/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js @@ -6,7 +6,7 @@ import React from 'react'; import { toastNotifications } from 'ui/notify'; -import { MarkdownSimple } from '../../../../../../../src/legacy/core_plugins/kibana_react/public'; +import { MarkdownSimple } from '../../../../../../../src/plugins/kibana_react/public'; import { PLUGIN } from '../../../common/constants'; export class LogstashLicenseService { From 8ef35c8f8726278adbd8a354dc992f4bfd559262 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Tue, 24 Mar 2020 09:28:29 +0100 Subject: [PATCH 064/179] [APM] add service map config options to legacy plugin (#61002) --- x-pack/legacy/plugins/apm/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index 0107997f233fe..6f238b48d9465 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -71,7 +71,12 @@ export const apm: LegacyPluginInitializer = kibana => { autocreateApmIndexPattern: Joi.boolean().default(true), // service map - serviceMapEnabled: Joi.boolean().default(true) + serviceMapEnabled: Joi.boolean().default(true), + serviceMapFingerprintBucketSize: Joi.number().default(100), + serviceMapTraceIdBucketSize: Joi.number().default(65), + serviceMapFingerprintGlobalBucketSize: Joi.number().default(1000), + serviceMapTraceIdGlobalBucketSize: Joi.number().default(6), + serviceMapMaxTracesPerRequest: Joi.number().default(50) }).default(); }, From a93efedcc55f9c664f2373afd722e8399257b895 Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Tue, 24 Mar 2020 09:46:59 +0100 Subject: [PATCH 065/179] Cahgen save object duplicate message (#60901) --- .../saved_objects/public/save_modal/saved_object_save_modal.tsx | 2 +- x-pack/plugins/translations/translations/ja-JP.json | 1 - x-pack/plugins/translations/translations/zh-CN.json | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx index 1d145bc97bdb4..95eb56c0e874b 100644 --- a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx @@ -289,7 +289,7 @@ export class SavedObjectSaveModal extends React.Component {

Date: Tue, 24 Mar 2020 09:43:11 +0000 Subject: [PATCH 066/179] [ML] Add support for percentiles aggregation to Transform wizard (#60763) * [ML] Add support for percentiles aggregation to Transform wizard * [ML] Fix type error and comments from review * [ML] Remove unused function Co-authored-by: Elastic Machine --- .../transform/public/app/common/index.ts | 2 + .../transform/public/app/common/pivot_aggs.ts | 22 +++- .../aggregation_list/popover_form.tsx | 104 +++++++++++++++++- .../components/step_define/common.test.ts | 8 ++ .../components/step_define/common.ts | 35 +++++- .../apps/transform/creation_index_pattern.ts | 54 +++++++++ 6 files changed, 217 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/transform/public/app/common/index.ts b/x-pack/plugins/transform/public/app/common/index.ts index ee026e2e590a4..f2b31bb5da865 100644 --- a/x-pack/plugins/transform/public/app/common/index.ts +++ b/x-pack/plugins/transform/public/app/common/index.ts @@ -40,6 +40,8 @@ export { GetTransformsResponse, PreviewData, PreviewMappings } from './pivot_pre export { getEsAggFromAggConfig, isPivotAggsConfigWithUiSupport, + isPivotAggsConfigPercentiles, + PERCENTILES_AGG_DEFAULT_PERCENTS, PivotAgg, PivotAggDict, PivotAggsConfig, diff --git a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts index 3ea614aaf5c9a..35dad3a8b2153 100644 --- a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts @@ -15,10 +15,13 @@ export enum PIVOT_SUPPORTED_AGGS { CARDINALITY = 'cardinality', MAX = 'max', MIN = 'min', + PERCENTILES = 'percentiles', SUM = 'sum', VALUE_COUNT = 'value_count', } +export const PERCENTILES_AGG_DEFAULT_PERCENTS = [1, 5, 25, 50, 75, 95, 99]; + export const pivotAggsFieldSupport = { [KBN_FIELD_TYPES.ATTACHMENT]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT], [KBN_FIELD_TYPES.BOOLEAN]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT], @@ -36,6 +39,7 @@ export const pivotAggsFieldSupport = { PIVOT_SUPPORTED_AGGS.CARDINALITY, PIVOT_SUPPORTED_AGGS.MAX, PIVOT_SUPPORTED_AGGS.MIN, + PIVOT_SUPPORTED_AGGS.PERCENTILES, PIVOT_SUPPORTED_AGGS.SUM, PIVOT_SUPPORTED_AGGS.VALUE_COUNT, ], @@ -60,10 +64,17 @@ export interface PivotAggsConfigBase { dropDownName: string; } -export interface PivotAggsConfigWithUiSupport extends PivotAggsConfigBase { +interface PivotAggsConfigWithUiBase extends PivotAggsConfigBase { field: EsFieldName; } +interface PivotAggsConfigPercentiles extends PivotAggsConfigWithUiBase { + agg: PIVOT_SUPPORTED_AGGS.PERCENTILES; + percents: number[]; +} + +export type PivotAggsConfigWithUiSupport = PivotAggsConfigWithUiBase | PivotAggsConfigPercentiles; + export function isPivotAggsConfigWithUiSupport(arg: any): arg is PivotAggsConfigWithUiSupport { return ( arg.hasOwnProperty('agg') && @@ -74,6 +85,15 @@ export function isPivotAggsConfigWithUiSupport(arg: any): arg is PivotAggsConfig ); } +export function isPivotAggsConfigPercentiles(arg: any): arg is PivotAggsConfigPercentiles { + return ( + arg.hasOwnProperty('agg') && + arg.hasOwnProperty('field') && + arg.hasOwnProperty('percents') && + arg.agg === PIVOT_SUPPORTED_AGGS.PERCENTILES + ); +} + export type PivotAggsConfig = PivotAggsConfigBase | PivotAggsConfigWithUiSupport; export type PivotAggsConfigWithUiSupportDict = Dictionary; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx index 6c1e119ab38e0..7157586dddda9 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx @@ -22,8 +22,10 @@ import { dictionaryToArray } from '../../../../../../common/types/common'; import { AggName, isAggName, + isPivotAggsConfigPercentiles, isPivotAggsConfigWithUiSupport, getEsAggFromAggConfig, + PERCENTILES_AGG_DEFAULT_PERCENTS, PivotAggsConfig, PivotAggsConfigWithUiSupportDict, PIVOT_SUPPORTED_AGGS, @@ -40,6 +42,33 @@ interface Props { onChange(d: PivotAggsConfig): void; } +function getDefaultPercents(defaultData: PivotAggsConfig): number[] | undefined { + if (isPivotAggsConfigPercentiles(defaultData)) { + return defaultData.percents; + } +} + +function parsePercentsInput(inputValue: string | undefined) { + if (inputValue !== undefined) { + const strVals: string[] = inputValue.split(','); + const percents: number[] = []; + for (const str of strVals) { + if (str.trim().length > 0 && isNaN(str as any) === false) { + const val = Number(str); + if (val >= 0 && val <= 100) { + percents.push(val); + } else { + return []; + } + } + } + + return percents; + } + + return []; +} + export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onChange, options }) => { const isUnsupportedAgg = !isPivotAggsConfigWithUiSupport(defaultData); @@ -48,10 +77,45 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha const [field, setField] = useState( isPivotAggsConfigWithUiSupport(defaultData) ? defaultData.field : '' ); + const [percents, setPercents] = useState(getDefaultPercents(defaultData)); const availableFields: SelectOption[] = []; const availableAggs: SelectOption[] = []; + function updateAgg(aggVal: PIVOT_SUPPORTED_AGGS) { + setAgg(aggVal); + if (aggVal === PIVOT_SUPPORTED_AGGS.PERCENTILES && percents === undefined) { + setPercents(PERCENTILES_AGG_DEFAULT_PERCENTS); + } + } + + function updatePercents(inputValue: string) { + setPercents(parsePercentsInput(inputValue)); + } + + function getUpdatedItem(): PivotAggsConfig { + let updatedItem: PivotAggsConfig; + + if (agg !== PIVOT_SUPPORTED_AGGS.PERCENTILES) { + updatedItem = { + agg, + aggName, + field, + dropDownName: defaultData.dropDownName, + }; + } else { + updatedItem = { + agg, + aggName, + field, + dropDownName: defaultData.dropDownName, + percents, + }; + } + + return updatedItem; + } + if (!isUnsupportedAgg) { const optionsArr = dictionaryToArray(options); optionsArr @@ -83,7 +147,18 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha }); } - const formValid = validAggName; + let percentsText; + if (percents !== undefined) { + percentsText = percents.toString(); + } + + const validPercents = + agg === PIVOT_SUPPORTED_AGGS.PERCENTILES && parsePercentsInput(percentsText).length > 0; + + let formValid = validAggName; + if (formValid && agg === PIVOT_SUPPORTED_AGGS.PERCENTILES) { + formValid = validPercents; + } return ( @@ -117,7 +192,7 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha setAgg(e.target.value as PIVOT_SUPPORTED_AGGS)} + onChange={e => updateAgg(e.target.value as PIVOT_SUPPORTED_AGGS)} /> )} @@ -134,6 +209,26 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha /> )} + {agg === PIVOT_SUPPORTED_AGGS.PERCENTILES && ( + + updatePercents(e.target.value)} + /> + + )} {isUnsupportedAgg && ( = ({ defaultData, otherAggNames, onCha /> )} - onChange({ ...defaultData, aggName, agg, field })} - > + onChange(getUpdatedItem())}> {i18n.translate('xpack.transform.agg.popoverForm.submitButtonLabel', { defaultMessage: 'Apply', })} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts index 5db6a233c9134..58ab4a1b8ac33 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts @@ -37,6 +37,7 @@ describe('Transform: Define Pivot Common', () => { { label: 'cardinality( the-f[i]e>ld )' }, { label: 'max( the-f[i]e>ld )' }, { label: 'min( the-f[i]e>ld )' }, + { label: 'percentiles( the-f[i]e>ld )' }, { label: 'sum( the-f[i]e>ld )' }, { label: 'value_count( the-f[i]e>ld )' }, ], @@ -67,6 +68,13 @@ describe('Transform: Define Pivot Common', () => { aggName: 'the-field.min', dropDownName: 'min( the-f[i]e>ld )', }, + 'percentiles( the-f[i]e>ld )': { + agg: 'percentiles', + field: ' the-f[i]e>ld ', + aggName: 'the-field.percentiles', + dropDownName: 'percentiles( the-f[i]e>ld )', + percents: [1, 5, 25, 50, 75, 95, 99], + }, 'sum( the-f[i]e>ld )': { agg: 'sum', field: ' the-f[i]e>ld ', diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts index a9413afb6243e..65cea40276da9 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts @@ -12,10 +12,13 @@ import { DropDownOption, EsFieldName, GroupByConfigWithUiSupport, + PERCENTILES_AGG_DEFAULT_PERCENTS, + PivotAggsConfigWithUiSupport, PivotAggsConfigWithUiSupportDict, pivotAggsFieldSupport, PivotGroupByConfigWithUiSupportDict, pivotGroupByFieldSupport, + PIVOT_SUPPORTED_AGGS, PIVOT_SUPPORTED_GROUP_BY_AGGS, } from '../../../../common'; @@ -57,6 +60,31 @@ function getDefaultGroupByConfig( } } +function getDefaultAggregationConfig( + aggName: string, + dropDownName: string, + fieldName: EsFieldName, + agg: PIVOT_SUPPORTED_AGGS +): PivotAggsConfigWithUiSupport { + switch (agg) { + case PIVOT_SUPPORTED_AGGS.PERCENTILES: + return { + agg, + aggName, + dropDownName, + field: fieldName, + percents: PERCENTILES_AGG_DEFAULT_PERCENTS, + }; + default: + return { + agg, + aggName, + dropDownName, + field: fieldName, + }; + } +} + const illegalEsAggNameChars = /[[\]>]/g; export function getPivotDropdownOptions(indexPattern: IndexPattern) { @@ -105,7 +133,12 @@ export function getPivotDropdownOptions(indexPattern: IndexPattern) { // Option name in the dropdown for the aggregation is in the form of `sum(fieldname)`. const dropDownName = `${agg}(${field.name})`; aggOption.options.push({ label: dropDownName }); - aggOptionsData[dropDownName] = { agg, field: field.name, aggName, dropDownName }; + aggOptionsData[dropDownName] = getDefaultAggregationConfig( + aggName, + dropDownName, + field.name, + agg + ); }); } aggOptions.push(aggOption); diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts index 4d1300ffaad06..ae3617db9e517 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -94,6 +94,60 @@ export default function({ getService }: FtrProviderContext) { }, }, }, + { + suiteTitle: 'batch transform with terms group and percentiles agg', + source: 'ecommerce', + groupByEntries: [ + { + identifier: 'terms(geoip.country_iso_code)', + label: 'geoip.country_iso_code', + } as GroupByEntry, + ], + aggregationEntries: [ + { + identifier: 'percentiles(products.base_price)', + label: 'products.base_price.percentiles', + }, + ], + transformId: `ec_2_${Date.now()}`, + transformDescription: + 'ecommerce batch transform with group by terms(geoip.country_iso_code) and aggregation percentiles(products.base_price)', + get destinationIndex(): string { + return `user-${this.transformId}`; + }, + expected: { + pivotAdvancedEditorValue: { + group_by: { + 'geoip.country_iso_code': { + terms: { + field: 'geoip.country_iso_code', + }, + }, + }, + aggregations: { + 'products.base_price.percentiles': { + percentiles: { + field: 'products.base_price', + percents: [1, 5, 25, 50, 75, 95, 99], + }, + }, + }, + }, + pivotPreview: { + column: 0, + values: ['AE', 'CO', 'EG', 'FR', 'GB'], + }, + row: { + status: 'stopped', + mode: 'batch', + progress: '100', + }, + sourcePreview: { + columns: 45, + rows: 5, + }, + }, + }, ]; for (const testData of testDataList) { From 85c0be357ae6b5ab6d0711bda5fe3c050f45fdaf Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Tue, 24 Mar 2020 11:15:10 +0100 Subject: [PATCH 067/179] [APM] Threshold alerts (#59566) * Add alerting/actions permissions for APM * Export TIME_UNITS, getTimeUnitLabel from triggers actions UI plugin * Add APM alert types and UI * Review feedback * Use Expression components for triggers * Update alert name for transaction duration * Change defaults for error rate trigger --- x-pack/legacy/plugins/apm/index.ts | 22 ++- .../__test__/APMIndicesPermission.test.tsx | 2 +- .../List/__test__/List.test.tsx | 28 +--- .../List/__test__/props.json | 7 - .../public/components/app/Home/Home.test.tsx | 2 +- .../app/Main/UpdateBreadcrumbs.test.tsx | 8 +- .../AlertingFlyout/index.tsx | 30 ++++ .../AlertIntegrations/index.tsx | 146 +++++++++++++++++ .../components/app/ServiceDetails/index.tsx | 20 +++ .../app/ServiceMap/Cytoscape.stories.tsx | 1 + .../app/ServiceMap/EmptyBanner.test.tsx | 2 +- .../components/app/ServiceMap/index.test.tsx | 2 +- .../app/ServiceNodeMetrics/index.test.tsx | 2 +- .../__test__/ServiceOverview.test.tsx | 4 +- .../app/Settings/ApmIndices/index.test.tsx | 2 +- .../CustomizeUI/CustomLink/index.test.tsx | 2 +- .../app/TraceLink/__test__/TraceLink.test.tsx | 2 +- .../__jest__/TransactionOverview.test.tsx | 2 +- .../ErrorRateAlertTrigger/index.stories.tsx | 26 +++ .../shared/ErrorRateAlertTrigger/index.tsx | 84 ++++++++++ .../__test__/ErrorMetadata.test.tsx | 4 +- .../__test__/SpanMetadata.test.tsx | 4 +- .../__test__/TransactionMetadata.test.tsx | 4 +- .../__test__/MetadataTable.test.tsx | 6 +- .../PopoverExpression/index.tsx | 39 +++++ .../shared/ServiceAlertTrigger/index.tsx | 61 +++++++ .../__test__/TransactionActionMenu.test.tsx | 2 +- .../index.stories.tsx | 50 ++++++ .../TransactionDurationAlertTrigger/index.tsx | 149 ++++++++++++++++++ .../BrowserLineChart.test.tsx | 2 +- .../ApmPluginContext/MockApmPluginContext.tsx | 63 ++++++++ .../index.tsx} | 2 +- .../MockUrlParamsContextProvider.tsx | 40 +++++ .../hooks/useFetcher.integration.test.tsx | 3 +- .../apm/public/hooks/useFetcher.test.tsx | 3 +- .../apm/public/new-platform/plugin.tsx | 76 +++++++-- .../plugins/apm/public/utils/testHelpers.tsx | 63 +------- x-pack/plugins/apm/common/alert_types.ts | 67 ++++++++ x-pack/plugins/apm/kibana.json | 2 +- .../server/lib/alerts/register_apm_alerts.ts | 29 ++++ .../alerts/register_error_rate_alert_type.ts | 108 +++++++++++++ ...egister_transaction_duration_alert_type.ts | 140 ++++++++++++++++ x-pack/plugins/apm/server/plugin.ts | 17 +- .../public/application/lib/capabilities.ts | 47 ++---- .../triggers_actions_ui/public/index.ts | 4 + 45 files changed, 1198 insertions(+), 181 deletions(-) create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx rename x-pack/legacy/plugins/apm/public/context/{ApmPluginContext.tsx => ApmPluginContext/index.tsx} (89%) create mode 100644 x-pack/legacy/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx create mode 100644 x-pack/plugins/apm/common/alert_types.ts create mode 100644 x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts create mode 100644 x-pack/plugins/apm/server/lib/alerts/register_error_rate_alert_type.ts create mode 100644 x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index 6f238b48d9465..6cfd18d0c1cba 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -91,24 +91,34 @@ export const apm: LegacyPluginInitializer = kibana => { navLinkId: 'apm', app: ['apm', 'kibana'], catalogue: ['apm'], + // see x-pack/plugins/features/common/feature_kibana_privileges.ts privileges: { all: { - api: ['apm', 'apm_write'], + api: ['apm', 'apm_write', 'actions-read', 'alerting-read'], catalogue: ['apm'], savedObject: { - all: [], + all: ['action', 'action_task_params'], read: [] }, - ui: ['show', 'save'] + ui: [ + 'show', + 'save', + 'alerting:show', + 'actions:show', + 'alerting:save', + 'actions:save', + 'alerting:delete', + 'actions:delete' + ] }, read: { - api: ['apm'], + api: ['apm', 'actions-read', 'alerting-read'], catalogue: ['apm'], savedObject: { - all: [], + all: ['action', 'action_task_params'], read: [] }, - ui: ['show'] + ui: ['show', 'alerting:show', 'actions:show'] } } }); diff --git a/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx index c2c396d5b8951..68acaee4abe5d 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx @@ -11,9 +11,9 @@ import { APMIndicesPermission } from '../'; import * as hooks from '../../../../hooks/useFetcher'; import { expectTextsInDocument, - MockApmPluginContextWrapper, expectTextsNotInDocument } from '../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; describe('APMIndicesPermission', () => { it('returns empty component when api status is loading', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx index 68d19a41f33a4..a09482d663f65 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx @@ -9,25 +9,7 @@ import React from 'react'; import { mockMoment, toJson } from '../../../../../utils/testHelpers'; import { ErrorGroupList } from '../index'; import props from './props.json'; -import { IUrlParams } from '../../../../../context/UrlParamsContext/types'; -import { - useUiFilters, - UrlParamsContext -} from '../../../../../context/UrlParamsContext'; - -const mockRefreshTimeRange = jest.fn(); -const MockUrlParamsProvider: React.FC<{ - params?: IUrlParams; -}> = ({ params = props.urlParams, children }) => ( - -); +import { MockUrlParamsContextProvider } from '../../../../../context/UrlParamsContext/MockUrlParamsContextProvider'; describe('ErrorGroupOverview -> List', () => { beforeAll(() => { @@ -37,9 +19,9 @@ describe('ErrorGroupOverview -> List', () => { it('should render empty state', () => { const storeState = {}; const wrapper = mount( - + - , + , storeState ); @@ -48,9 +30,9 @@ describe('ErrorGroupOverview -> List', () => { it('should render with data', () => { const wrapper = mount( - + - + ); expect(toJson(wrapper)).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json index 92198220628d1..431a6c71b103b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json +++ b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json @@ -1,11 +1,4 @@ { - "urlParams": { - "page": 0, - "serviceName": "opbeans-python", - "transactionType": "request", - "start": "2018-01-10T09:51:41.050Z", - "end": "2018-01-10T10:06:41.050Z" - }, "items": [ { "message": "About to blow up!", diff --git a/x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx index 711290942cea1..ab4ca1dfbb49d 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx @@ -7,7 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; import { Home } from '../Home'; -import { MockApmPluginContextWrapper } from '../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; describe('Home component', () => { it('should render services', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx index 5bf8cb8271fa4..e610f3b84899b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx @@ -8,12 +8,12 @@ import { mount } from 'enzyme'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { ApmPluginContextValue } from '../../../context/ApmPluginContext'; -import { - mockApmPluginContextValue, - MockApmPluginContextWrapper -} from '../../../utils/testHelpers'; import { routes } from './route_config'; import { UpdateBreadcrumbs } from './UpdateBreadcrumbs'; +import { + MockApmPluginContextWrapper, + mockApmPluginContextValue +} from '../../../context/ApmPluginContext/MockApmPluginContext'; const setBreadcrumbs = jest.fn(); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx new file mode 100644 index 0000000000000..7e8d057a7be6c --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx @@ -0,0 +1,30 @@ +/* + * 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 from 'react'; +import { AlertType } from '../../../../../../../../../plugins/apm/common/alert_types'; +import { AlertAdd } from '../../../../../../../../../plugins/triggers_actions_ui/public'; + +type AlertAddProps = React.ComponentProps; + +interface Props { + addFlyoutVisible: AlertAddProps['addFlyoutVisible']; + setAddFlyoutVisibility: AlertAddProps['setAddFlyoutVisibility']; + alertType: AlertType | null; +} + +export function AlertingFlyout(props: Props) { + const { addFlyoutVisible, setAddFlyoutVisibility, alertType } = props; + + return alertType ? ( + + ) : null; +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx new file mode 100644 index 0000000000000..92b325ab00d35 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx @@ -0,0 +1,146 @@ +/* + * 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 { + EuiButtonEmpty, + EuiContextMenu, + EuiPopover, + EuiContextMenuPanelDescriptor +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { AlertType } from '../../../../../../../../plugins/apm/common/alert_types'; +import { AlertingFlyout } from './AlertingFlyout'; +import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; + +const alertLabel = i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.alerts', + { + defaultMessage: 'Alerts' + } +); + +const createThresholdAlertLabel = i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.createThresholdAlert', + { + defaultMessage: 'Create threshold alert' + } +); + +const CREATE_THRESHOLD_ALERT_PANEL_ID = 'create_threshold'; + +interface Props { + canReadAlerts: boolean; + canSaveAlerts: boolean; +} + +export function AlertIntegrations(props: Props) { + const { canSaveAlerts, canReadAlerts } = props; + + const plugin = useApmPluginContext(); + + const [popoverOpen, setPopoverOpen] = useState(false); + + const [alertType, setAlertType] = useState(null); + + const button = ( + setPopoverOpen(true)} + > + {i18n.translate('xpack.apm.serviceDetails.alertsMenu.alerts', { + defaultMessage: 'Alerts' + })} + + ); + + const panels: EuiContextMenuPanelDescriptor[] = [ + { + id: 0, + title: alertLabel, + items: [ + ...(canSaveAlerts + ? [ + { + name: createThresholdAlertLabel, + panel: CREATE_THRESHOLD_ALERT_PANEL_ID, + icon: 'bell' + } + ] + : []), + ...(canReadAlerts + ? [ + { + name: i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.viewActiveAlerts', + { + defaultMessage: 'View active alerts' + } + ), + href: plugin.core.http.basePath.prepend( + '/app/kibana#/management/kibana/triggersActions/alerts' + ), + icon: 'tableOfContents' + } + ] + : []) + ] + }, + { + id: CREATE_THRESHOLD_ALERT_PANEL_ID, + title: createThresholdAlertLabel, + items: [ + { + name: i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.transactionDuration', + { + defaultMessage: 'Transaction duration' + } + ), + onClick: () => { + setAlertType(AlertType.TransactionDuration); + } + }, + { + name: i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.errorRate', + { + defaultMessage: 'Error rate' + } + ), + onClick: () => { + setAlertType(AlertType.ErrorRate); + } + } + ] + } + ]; + + return ( + <> + setPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="downRight" + > + + + { + if (!visible) { + setAlertType(null); + } + }} + /> + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx index ac7dfd49d4f3d..77ae67b71e1b6 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx @@ -10,15 +10,27 @@ import { ApmHeader } from '../../shared/ApmHeader'; import { ServiceDetailTabs } from './ServiceDetailTabs'; import { ServiceIntegrations } from './ServiceIntegrations'; import { useUrlParams } from '../../../hooks/useUrlParams'; +import { AlertIntegrations } from './AlertIntegrations'; +import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; interface Props { tab: React.ComponentProps['tab']; } export function ServiceDetails({ tab }: Props) { + const plugin = useApmPluginContext(); const { urlParams } = useUrlParams(); const { serviceName } = urlParams; + const canReadAlerts = !!plugin.core.application.capabilities.apm[ + 'alerting:show' + ]; + const canSaveAlerts = !!plugin.core.application.capabilities.apm[ + 'alerting:save' + ]; + + const isAlertingAvailable = canReadAlerts || canSaveAlerts; + return (

@@ -31,6 +43,14 @@ export function ServiceDetails({ tab }: Props) { + {isAlertingAvailable && ( + + + + )} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx index 7a066b520cc3b..46754c8c7cb6b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx @@ -194,6 +194,7 @@ storiesOf('app/ServiceMap/Cytoscape', module) const height = 640; const width = 1340; const serviceName = undefined; // global service map + return ( { describe('render', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx index 241f272b54a1d..b286d33ca74e9 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx @@ -11,11 +11,11 @@ import * as urlParamsHooks from '../../../../hooks/useUrlParams'; import * as useLocalUIFilters from '../../../../hooks/useLocalUIFilters'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { SessionStorageMock } from '../../../../services/__test__/SessionStorageMock'; +import { ApmPluginContextValue } from '../../../../context/ApmPluginContext'; import { MockApmPluginContextWrapper, mockApmPluginContextValue -} from '../../../../utils/testHelpers'; -import { ApmPluginContextValue } from '../../../../context/ApmPluginContext'; +} from '../../../../context/ApmPluginContext/MockApmPluginContext'; jest.mock('ui/new_platform'); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx index fd71bf9709ce9..272c4b3add415 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx @@ -7,8 +7,8 @@ import { render, wait } from '@testing-library/react'; import React from 'react'; import { ApmIndices } from '.'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; import * as hooks from '../../../../hooks/useFetcher'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; describe('ApmIndices', () => { it('should not get stuck in infinite loop', async () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx index 7c39356189891..b5bee5a5a1ebb 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx @@ -13,11 +13,11 @@ import * as hooks from '../../../../../hooks/useFetcher'; import { LicenseContext } from '../../../../../context/LicenseContext'; import { CustomLinkOverview } from '.'; import { - MockApmPluginContextWrapper, expectTextsInDocument, expectTextsNotInDocument } from '../../../../../utils/testHelpers'; import * as saveCustomLink from './CustomLinkFlyout/saveCustomLink'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; const data = [ { diff --git a/x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx index fe58fc39c6cfa..b8d6d9818eb2c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { TraceLink } from '../'; import * as hooks from '../../../../hooks/useFetcher'; import * as urlParamsHooks from '../../../../hooks/useUrlParams'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper }; diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx index 882682f1f6760..22cbeee5c6b7c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx @@ -22,7 +22,7 @@ import * as useFetcherHook from '../../../../hooks/useFetcher'; import { fromQuery } from '../../../shared/Links/url_helpers'; import { Router } from 'react-router-dom'; import { UrlParamsProvider } from '../../../../context/UrlParamsContext'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; jest.spyOn(history, 'push'); jest.spyOn(history, 'replace'); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx b/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx new file mode 100644 index 0000000000000..4ef8de7c2b208 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx @@ -0,0 +1,26 @@ +/* + * 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 { storiesOf } from '@storybook/react'; +import React from 'react'; +import { ErrorRateAlertTrigger } from '.'; + +storiesOf('app/ErrorRateAlertTrigger', module).add('example', props => { + const params = { + threshold: 2, + window: '5m' + }; + + return ( +
+ undefined} + setAlertProperty={() => undefined} + /> +
+ ); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx new file mode 100644 index 0000000000000..6d0a2b96092a1 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx @@ -0,0 +1,84 @@ +/* + * 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 from 'react'; +import { EuiFieldNumber } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ForLastExpression } from '../../../../../../../plugins/triggers_actions_ui/public'; +import { ALERT_TYPES_CONFIG } from '../../../../../../../plugins/apm/common/alert_types'; +import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; +import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; + +export interface ErrorRateAlertTriggerParams { + windowSize: number; + windowUnit: string; + threshold: number; +} + +interface Props { + alertParams: ErrorRateAlertTriggerParams; + setAlertParams: (key: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; +} + +export function ErrorRateAlertTrigger(props: Props) { + const { setAlertParams, setAlertProperty, alertParams } = props; + + const defaults = { + threshold: 25, + windowSize: 1, + windowUnit: 'm' + }; + + const params = { + ...defaults, + ...alertParams + }; + + const fields = [ + + + setAlertParams('threshold', parseInt(e.target.value, 10)) + } + compressed + append={i18n.translate('xpack.apm.errorRateAlertTrigger.errors', { + defaultMessage: 'errors' + })} + /> + , + + setAlertParams('windowSize', windowSize) + } + onChangeWindowUnit={windowUnit => + setAlertParams('windowUnit', windowUnit) + } + timeWindowSize={params.windowSize} + timeWindowUnit={params.windowUnit} + errors={{ + timeWindowSize: [], + timeWindowUnit: [] + }} + /> + ]; + + return ( + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx index 0c60d523b8f3f..258788252379a 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx @@ -10,9 +10,9 @@ import { render } from '@testing-library/react'; import { APMError } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/apm_error'; import { expectTextsInDocument, - expectTextsNotInDocument, - MockApmPluginContextWrapper + expectTextsNotInDocument } from '../../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx index ee66636d88ba9..0059b7b8fb4b3 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx @@ -10,9 +10,9 @@ import { SpanMetadata } from '..'; import { Span } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/span'; import { expectTextsInDocument, - expectTextsNotInDocument, - MockApmPluginContextWrapper + expectTextsNotInDocument } from '../../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx index f426074fbef80..3d78f36db9786 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx @@ -10,9 +10,9 @@ import { render } from '@testing-library/react'; import { Transaction } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; import { expectTextsInDocument, - expectTextsNotInDocument, - MockApmPluginContextWrapper + expectTextsNotInDocument } from '../../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx index 979b9118a7534..96202525c8661 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx @@ -7,11 +7,9 @@ import React from 'react'; import { render } from '@testing-library/react'; import { MetadataTable } from '..'; -import { - expectTextsInDocument, - MockApmPluginContextWrapper -} from '../../../../utils/testHelpers'; +import { expectTextsInDocument } from '../../../../utils/testHelpers'; import { SectionsWithRows } from '../helper'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx new file mode 100644 index 0000000000000..1abdb94c8313e --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { EuiExpression, EuiPopover } from '@elastic/eui'; + +interface Props { + title: string; + value: string; + children?: React.ReactNode; +} + +export const PopoverExpression = (props: Props) => { + const { title, value, children } = props; + + const [popoverOpen, setPopoverOpen] = useState(false); + + return ( + setPopoverOpen(false)} + button={ + setPopoverOpen(true)} + /> + } + > + {children} + + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx new file mode 100644 index 0000000000000..98391b277caf6 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx @@ -0,0 +1,61 @@ +/* + * 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, { useEffect } from 'react'; +import { EuiSpacer, EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; +import { useUrlParams } from '../../../hooks/useUrlParams'; + +interface Props { + alertTypeName: string; + setAlertParams: (key: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; + defaults: Record; + fields: React.ReactNode[]; +} + +export function ServiceAlertTrigger(props: Props) { + const { urlParams } = useUrlParams(); + + const { + fields, + setAlertParams, + setAlertProperty, + alertTypeName, + defaults + } = props; + + const params: Record = { + ...defaults, + serviceName: urlParams.serviceName! + }; + + useEffect(() => { + // we only want to run this on mount to set default values + setAlertProperty('name', `${alertTypeName} | ${params.serviceName}`); + setAlertProperty('tags', [ + 'apm', + `service.name:${params.serviceName}`.toLowerCase() + ]); + Object.keys(params).forEach(key => { + setAlertParams(key, params[key]); + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( + <> + + + {fields.map((field, index) => ( + + {field} + + ))} + + + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx index 9094662e34914..560884aec554a 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx @@ -10,13 +10,13 @@ import { TransactionActionMenu } from '../TransactionActionMenu'; import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; import * as Transactions from './mockData'; import { - MockApmPluginContextWrapper, expectTextsNotInDocument, expectTextsInDocument } from '../../../../utils/testHelpers'; import * as hooks from '../../../../hooks/useFetcher'; import { LicenseContext } from '../../../../context/LicenseContext'; import { License } from '../../../../../../../../plugins/licensing/common/license'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; const renderTransaction = async (transaction: Record) => { const rendered = render( diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx new file mode 100644 index 0000000000000..a8f834103e6c1 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx @@ -0,0 +1,50 @@ +/* + * 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 { cloneDeep, merge } from 'lodash'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { TransactionDurationAlertTrigger } from '.'; +import { + MockApmPluginContextWrapper, + mockApmPluginContextValue +} from '../../../context/ApmPluginContext/MockApmPluginContext'; +import { MockUrlParamsContextProvider } from '../../../context/UrlParamsContext/MockUrlParamsContextProvider'; +import { ApmPluginContextValue } from '../../../context/ApmPluginContext'; + +storiesOf('app/TransactionDurationAlertTrigger', module).add( + 'example', + context => { + const params = { + threshold: 1500, + aggregationType: 'avg' as const, + window: '5m' + }; + + const contextMock = (merge(cloneDeep(mockApmPluginContextValue), { + core: { + http: { + get: () => { + return Promise.resolve({ transactionTypes: ['request'] }); + } + } + } + }) as unknown) as ApmPluginContextValue; + + return ( +
+ + + undefined} + setAlertProperty={() => undefined} + /> + + +
+ ); + } +); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx new file mode 100644 index 0000000000000..cdc7c30089b4f --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx @@ -0,0 +1,149 @@ +/* + * 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 from 'react'; +import { map } from 'lodash'; +import { EuiFieldNumber, EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ForLastExpression } from '../../../../../../../plugins/triggers_actions_ui/public'; +import { + TRANSACTION_ALERT_AGGREGATION_TYPES, + ALERT_TYPES_CONFIG +} from '../../../../../../../plugins/apm/common/alert_types'; +import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes'; +import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; + +interface Params { + windowSize: number; + windowUnit: string; + threshold: number; + aggregationType: 'avg' | '95th' | '99th'; + serviceName: string; + transactionType: string; +} + +interface Props { + alertParams: Params; + setAlertParams: (key: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; +} + +export function TransactionDurationAlertTrigger(props: Props) { + const { setAlertParams, alertParams, setAlertProperty } = props; + + const { urlParams } = useUrlParams(); + + const transactionTypes = useServiceTransactionTypes(urlParams); + + if (!transactionTypes.length) { + return null; + } + + const defaults = { + threshold: 1500, + aggregationType: 'avg', + windowSize: 5, + windowUnit: 'm', + transactionType: transactionTypes[0] + }; + + const params = { + ...defaults, + ...alertParams + }; + + const fields = [ + + { + return { + text: key, + value: key + }; + })} + onChange={e => + setAlertParams( + 'transactionType', + e.target.value as Params['transactionType'] + ) + } + compressed + /> + , + + { + return { + text: label, + value: key + }; + })} + onChange={e => + setAlertParams( + 'aggregationType', + e.target.value as Params['aggregationType'] + ) + } + compressed + /> + , + + setAlertParams('threshold', e.target.value)} + append={i18n.translate('xpack.apm.transactionDurationAlertTrigger.ms', { + defaultMessage: 'ms' + })} + compressed + /> + , + + setAlertParams('windowSize', timeWindowSize) + } + onChangeWindowUnit={timeWindowUnit => + setAlertParams('windowUnit', timeWindowUnit) + } + timeWindowSize={params.windowSize} + timeWindowUnit={params.windowUnit} + errors={{ + timeWindowSize: [], + timeWindowUnit: [] + }} + /> + ]; + + return ( + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx index 6d3e29ec09985..9f112475a4a78 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { BrowserLineChart } from './BrowserLineChart'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; describe('BrowserLineChart', () => { describe('render', () => { diff --git a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx new file mode 100644 index 0000000000000..8775dc98c3e1a --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx @@ -0,0 +1,63 @@ +/* + * 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 from 'react'; +import { ApmPluginContext, ApmPluginContextValue } from '.'; +import { createCallApmApi } from '../../services/rest/createCallApmApi'; +import { ConfigSchema } from '../../new-platform/plugin'; + +const mockCore = { + chrome: { + setBreadcrumbs: () => {} + }, + http: { + basePath: { + prepend: (path: string) => `/basepath${path}` + } + }, + notifications: { + toasts: { + addWarning: () => {}, + addDanger: () => {} + } + } +}; + +const mockConfig: ConfigSchema = { + indexPatternTitle: 'apm-*', + serviceMapEnabled: true, + ui: { + enabled: false + } +}; + +export const mockApmPluginContextValue = { + config: mockConfig, + core: mockCore, + packageInfo: { version: '0' }, + plugins: {} +}; + +export function MockApmPluginContextWrapper({ + children, + value = {} as ApmPluginContextValue +}: { + children?: React.ReactNode; + value?: ApmPluginContextValue; +}) { + if (value.core?.http) { + createCallApmApi(value.core?.http); + } + return ( + + {children} + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext.tsx b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/index.tsx similarity index 89% rename from x-pack/legacy/plugins/apm/public/context/ApmPluginContext.tsx rename to x-pack/legacy/plugins/apm/public/context/ApmPluginContext/index.tsx index 7a9aaa6dfb920..d8934ba4b0151 100644 --- a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext.tsx +++ b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/index.tsx @@ -6,7 +6,7 @@ import { createContext } from 'react'; import { AppMountContext, PackageInfo } from 'kibana/public'; -import { ApmPluginSetupDeps, ConfigSchema } from '../new-platform/plugin'; +import { ApmPluginSetupDeps, ConfigSchema } from '../../new-platform/plugin'; export type AppMountContextBasePath = AppMountContext['core']['http']['basePath']; diff --git a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx new file mode 100644 index 0000000000000..46f51da49692a --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { IUrlParams } from './types'; +import { UrlParamsContext, useUiFilters } from '.'; + +const defaultUrlParams = { + page: 0, + serviceName: 'opbeans-python', + transactionType: 'request', + start: '2018-01-10T09:51:41.050Z', + end: '2018-01-10T10:06:41.050Z' +}; + +interface Props { + params?: IUrlParams; + children: React.ReactNode; + refreshTimeRange?: (time: any) => void; +} + +export const MockUrlParamsContextProvider = ({ + params, + children, + refreshTimeRange = () => undefined +}: Props) => { + const urlParams = { ...defaultUrlParams, ...params }; + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx index 8d8716e6e5cd7..8918d992b4f53 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx +++ b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx @@ -6,8 +6,9 @@ import { render, wait } from '@testing-library/react'; import React from 'react'; -import { delay, MockApmPluginContextWrapper } from '../utils/testHelpers'; +import { delay } from '../utils/testHelpers'; import { useFetcher } from './useFetcher'; +import { MockApmPluginContextWrapper } from '../context/ApmPluginContext/MockApmPluginContext'; const wrapper = MockApmPluginContextWrapper; diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx index e3ef1d44c8b03..deb805c542b1e 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx +++ b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx @@ -5,8 +5,9 @@ */ import { renderHook } from '@testing-library/react-hooks'; -import { delay, MockApmPluginContextWrapper } from '../utils/testHelpers'; +import { delay } from '../utils/testHelpers'; import { useFetcher } from './useFetcher'; +import { MockApmPluginContextWrapper } from '../context/ApmPluginContext/MockApmPluginContext'; // Wrap the hook with a provider so it can useApmPluginContext const wrapper = MockApmPluginContextWrapper; diff --git a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx index 0103dd72a3fea..f95767492d85b 100644 --- a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx +++ b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx @@ -10,6 +10,8 @@ import { Route, Router, Switch } from 'react-router-dom'; import { ApmRoute } from '@elastic/apm-rum-react'; import styled from 'styled-components'; import { metadata } from 'ui/metadata'; +import { i18n } from '@kbn/i18n'; +import { AlertType } from '../../../../../plugins/apm/common/alert_types'; import { CoreSetup, CoreStart, @@ -39,6 +41,12 @@ import { toggleAppLinkInNav } from './toggleAppLinkInNav'; import { setReadonlyBadge } from './updateBadge'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { APMIndicesPermission } from '../components/app/APMIndicesPermission'; +import { + TriggersAndActionsUIPublicPluginSetup, + AlertsContextProvider +} from '../../../../../plugins/triggers_actions_ui/public'; +import { ErrorRateAlertTrigger } from '../components/shared/ErrorRateAlertTrigger'; +import { TransactionDurationAlertTrigger } from '../components/shared/TransactionDurationAlertTrigger'; import { createCallApmApi } from '../services/rest/createCallApmApi'; export const REACT_APP_ROOT_ID = 'react-apm-root'; @@ -72,6 +80,7 @@ export interface ApmPluginSetupDeps { data: DataPublicPluginSetup; home: HomePublicPluginSetup; licensing: LicensingPluginSetup; + triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; } export interface ConfigSchema { @@ -135,25 +144,58 @@ export class ApmPlugin plugins }; + plugins.triggers_actions_ui.alertTypeRegistry.register({ + id: AlertType.ErrorRate, + name: i18n.translate('xpack.apm.alertTypes.errorRate', { + defaultMessage: 'Error rate' + }), + iconClass: 'bell', + alertParamsExpression: ErrorRateAlertTrigger, + validate: () => ({ + errors: [] + }) + }); + + plugins.triggers_actions_ui.alertTypeRegistry.register({ + id: AlertType.TransactionDuration, + name: i18n.translate('xpack.apm.alertTypes.transactionDuration', { + defaultMessage: 'Transaction duration' + }), + iconClass: 'bell', + alertParamsExpression: TransactionDurationAlertTrigger, + validate: () => ({ + errors: [] + }) + }); + ReactDOM.render( - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + , document.getElementById(REACT_APP_ROOT_ID) ); diff --git a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx b/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx index 6bcfbc4541b64..36c0e18777bfd 100644 --- a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx +++ b/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx @@ -11,7 +11,7 @@ import enzymeToJson from 'enzyme-to-json'; import { Location } from 'history'; import moment from 'moment'; import { Moment } from 'moment-timezone'; -import React, { ReactNode } from 'react'; +import React from 'react'; import { render, waitForElement } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { MemoryRouter } from 'react-router-dom'; @@ -24,12 +24,7 @@ import { ESSearchResponse, ESSearchRequest } from '../../../../../plugins/apm/typings/elasticsearch'; -import { - ApmPluginContext, - ApmPluginContextValue -} from '../context/ApmPluginContext'; -import { ConfigSchema } from '../new-platform/plugin'; -import { createCallApmApi } from '../services/rest/createCallApmApi'; +import { MockApmPluginContextWrapper } from '../context/ApmPluginContext/MockApmPluginContext'; export function toJson(wrapper: ReactWrapper) { return enzymeToJson(wrapper, { @@ -186,57 +181,3 @@ export async function inspectSearchParams( } export type SearchParamsMock = PromiseReturnType; - -const mockCore = { - chrome: { - setBreadcrumbs: () => {} - }, - http: { - basePath: { - prepend: (path: string) => `/basepath${path}` - } - }, - notifications: { - toasts: { - addWarning: () => {}, - addDanger: () => {} - } - } -}; - -const mockConfig: ConfigSchema = { - indexPatternTitle: 'apm-*', - serviceMapEnabled: true, - ui: { - enabled: false - } -}; - -export const mockApmPluginContextValue = { - config: mockConfig, - core: mockCore, - packageInfo: { version: '0' }, - plugins: {} -}; - -export function MockApmPluginContextWrapper({ - children, - value = {} as ApmPluginContextValue -}: { - children?: ReactNode; - value?: ApmPluginContextValue; -}) { - if (value.core?.http) { - createCallApmApi(value.core?.http); - } - return ( - - {children} - - ); -} diff --git a/x-pack/plugins/apm/common/alert_types.ts b/x-pack/plugins/apm/common/alert_types.ts new file mode 100644 index 0000000000000..51e1f88512965 --- /dev/null +++ b/x-pack/plugins/apm/common/alert_types.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export enum AlertType { + ErrorRate = 'apm.error_rate', + TransactionDuration = 'apm.transaction_duration' +} + +export const ALERT_TYPES_CONFIG = { + [AlertType.ErrorRate]: { + name: i18n.translate('xpack.apm.errorRateAlert.name', { + defaultMessage: 'Error rate threshold' + }), + actionGroups: [ + { + id: 'threshold_met', + name: i18n.translate('xpack.apm.errorRateAlert.thresholdMet', { + defaultMessage: 'Threshold met' + }) + } + ], + defaultActionGroupId: 'threshold_met' + }, + [AlertType.TransactionDuration]: { + name: i18n.translate('xpack.apm.transactionDurationAlert.name', { + defaultMessage: 'Transaction duration threshold' + }), + actionGroups: [ + { + id: 'threshold_met', + name: i18n.translate( + 'xpack.apm.transactionDurationAlert.thresholdMet', + { + defaultMessage: 'Threshold met' + } + ) + } + ], + defaultActionGroupId: 'threshold_met' + } +}; + +export const TRANSACTION_ALERT_AGGREGATION_TYPES = { + avg: i18n.translate( + 'xpack.apm.transactionDurationAlert.aggregationType.avg', + { + defaultMessage: 'Average' + } + ), + '95th': i18n.translate( + 'xpack.apm.transactionDurationAlert.aggregationType.95th', + { + defaultMessage: '95th percentile' + } + ), + '99th': i18n.translate( + 'xpack.apm.transactionDurationAlert.aggregationType.99th', + { + defaultMessage: '99th percentile' + } + ) +}; diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 96579377c95e8..931fd92e1ecc3 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -6,5 +6,5 @@ "configPath": ["xpack", "apm"], "ui": false, "requiredPlugins": ["apm_oss", "data", "home", "licensing"], - "optionalPlugins": ["cloud", "usageCollection"] + "optionalPlugins": ["cloud", "usageCollection", "taskManager","actions", "alerting"] } diff --git a/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts b/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts new file mode 100644 index 0000000000000..cb3dd761040da --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts @@ -0,0 +1,29 @@ +/* + * 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 { Observable } from 'rxjs'; +import { AlertingPlugin } from '../../../../alerting/server'; +import { ActionsPlugin } from '../../../../actions/server'; +import { registerTransactionDurationAlertType } from './register_transaction_duration_alert_type'; +import { registerErrorRateAlertType } from './register_error_rate_alert_type'; +import { APMConfig } from '../..'; + +interface Params { + alerting: AlertingPlugin['setup']; + actions: ActionsPlugin['setup']; + config$: Observable; +} + +export function registerApmAlerts(params: Params) { + registerTransactionDurationAlertType({ + alerting: params.alerting, + config$: params.config$ + }); + registerErrorRateAlertType({ + alerting: params.alerting, + config$: params.config$ + }); +} diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_rate_alert_type.ts new file mode 100644 index 0000000000000..187a75d0b61f2 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_rate_alert_type.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; +import { + ESSearchResponse, + ESSearchRequest +} from '../../../typings/elasticsearch'; +import { + PROCESSOR_EVENT, + SERVICE_NAME +} from '../../../common/elasticsearch_fieldnames'; +import { AlertingPlugin } from '../../../../alerting/server'; +import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; +import { APMConfig } from '../..'; + +interface RegisterAlertParams { + alerting: AlertingPlugin['setup']; + config$: Observable; +} + +const paramsSchema = schema.object({ + serviceName: schema.string(), + windowSize: schema.number(), + windowUnit: schema.string(), + threshold: schema.number() +}); + +const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.ErrorRate]; + +export function registerErrorRateAlertType({ + alerting, + config$ +}: RegisterAlertParams) { + alerting.registerType({ + id: AlertType.ErrorRate, + name: alertTypeConfig.name, + actionGroups: alertTypeConfig.actionGroups, + defaultActionGroupId: alertTypeConfig.defaultActionGroupId, + validate: { + params: paramsSchema + }, + + executor: async ({ services, params }) => { + const config = await config$.pipe(take(1)).toPromise(); + + const alertParams = params as TypeOf; + + const indices = await getApmIndices({ + config, + savedObjectsClient: services.savedObjectsClient + }); + + const searchParams = { + index: indices['apm_oss.errorIndices'], + size: 0, + body: { + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: `now-${alertParams.windowSize}${alertParams.windowUnit}` + } + } + }, + { + term: { + [PROCESSOR_EVENT]: 'error' + } + }, + { + term: { + [SERVICE_NAME]: alertParams.serviceName + } + } + ] + } + }, + track_total_hits: true + } + }; + + const response: ESSearchResponse< + unknown, + ESSearchRequest + > = await services.callCluster('search', searchParams); + + const value = response.hits.total.value; + + if (value && value > alertParams.threshold) { + const alertInstance = services.alertInstanceFactory( + AlertType.ErrorRate + ); + alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId); + } + + return {}; + } + }); +} diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts new file mode 100644 index 0000000000000..7575a8268bc26 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; +import { ESSearchResponse } from '../../../typings/elasticsearch'; +import { + PROCESSOR_EVENT, + SERVICE_NAME, + TRANSACTION_TYPE, + TRANSACTION_DURATION +} from '../../../common/elasticsearch_fieldnames'; +import { AlertingPlugin } from '../../../../alerting/server'; +import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; +import { APMConfig } from '../..'; + +interface RegisterAlertParams { + alerting: AlertingPlugin['setup']; + config$: Observable; +} + +const paramsSchema = schema.object({ + serviceName: schema.string(), + transactionType: schema.string(), + windowSize: schema.number(), + windowUnit: schema.string(), + threshold: schema.number(), + aggregationType: schema.oneOf([ + schema.literal('avg'), + schema.literal('95th'), + schema.literal('99th') + ]) +}); + +const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.TransactionDuration]; + +export function registerTransactionDurationAlertType({ + alerting, + config$ +}: RegisterAlertParams) { + alerting.registerType({ + id: AlertType.TransactionDuration, + name: alertTypeConfig.name, + actionGroups: alertTypeConfig.actionGroups, + defaultActionGroupId: alertTypeConfig.defaultActionGroupId, + validate: { + params: paramsSchema + }, + + executor: async ({ services, params }) => { + const config = await config$.pipe(take(1)).toPromise(); + + const alertParams = params as TypeOf; + + const indices = await getApmIndices({ + config, + savedObjectsClient: services.savedObjectsClient + }); + + const searchParams = { + index: indices['apm_oss.transactionIndices'], + size: 0, + body: { + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: `now-${alertParams.windowSize}${alertParams.windowUnit}` + } + } + }, + { + term: { + [PROCESSOR_EVENT]: 'transaction' + } + }, + { + term: { + [SERVICE_NAME]: alertParams.serviceName + } + }, + { + term: { + [TRANSACTION_TYPE]: alertParams.transactionType + } + } + ] + } + }, + aggs: { + agg: + alertParams.aggregationType === 'avg' + ? { + avg: { + field: TRANSACTION_DURATION + } + } + : { + percentiles: { + field: TRANSACTION_DURATION, + percents: [ + alertParams.aggregationType === '95th' ? 95 : 99 + ] + } + } + } + } + }; + + const response: ESSearchResponse< + unknown, + typeof searchParams + > = await services.callCluster('search', searchParams); + + if (!response.aggregations) { + return; + } + + const { agg } = response.aggregations; + + const value = 'values' in agg ? agg.values[0] : agg.value; + + if (value && value > alertParams.threshold * 1000) { + const alertInstance = services.alertInstanceFactory( + AlertType.TransactionDuration + ); + alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId); + } + + return {}; + } + }); +} diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index db14730f802a9..e140340786e8a 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -8,7 +8,10 @@ import { Observable, combineLatest, AsyncSubject } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { Server } from 'hapi'; import { once } from 'lodash'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; +import { TaskManagerSetupContract } from '../../task_manager/server'; +import { AlertingPlugin } from '../../alerting/server'; +import { ActionsPlugin } from '../../actions/server'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; import { makeApmUsageCollector } from './lib/apm_telemetry'; import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; @@ -21,6 +24,7 @@ import { tutorialProvider } from './tutorial'; import { CloudSetup } from '../../cloud/server'; import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; import { LicensingPluginSetup } from '../../licensing/public'; +import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; export interface LegacySetup { server: Server; @@ -47,6 +51,9 @@ export class APMPlugin implements Plugin { licensing: LicensingPluginSetup; cloud?: CloudSetup; usageCollection?: UsageCollectionSetup; + taskManager?: TaskManagerSetupContract; + alerting?: AlertingPlugin['setup']; + actions?: ActionsPlugin['setup']; } ) { const logger = this.initContext.logger.get('apm'); @@ -55,6 +62,14 @@ export class APMPlugin implements Plugin { map(([apmOssConfig, apmConfig]) => mergeConfigs(apmOssConfig, apmConfig)) ); + if (plugins.actions && plugins.alerting) { + registerApmAlerts({ + alerting: plugins.alerting, + actions: plugins.actions, + config$: mergedConfig$ + }); + } + this.legacySetup$.subscribe(__LEGACY => { createApmApi().init(core, { config$: mergedConfig$, logger, __LEGACY }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts index e5693e31c2d66..f8102189c425c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts @@ -10,44 +10,21 @@ * will possibly go away with https://github.com/elastic/kibana/issues/52300. */ -export function hasShowAlertsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['alerting:show']) { - return true; - } - return false; -} +type Capabilities = Record; -export function hasShowActionsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['actions:show']) { - return true; - } - return false; -} +const apps = ['apm', 'siem']; -export function hasSaveAlertsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['alerting:save']) { - return true; - } - return false; +function hasCapability(capabilities: Capabilities, capability: string) { + return apps.some(app => capabilities[app]?.[capability]); } -export function hasSaveActionsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['actions:save']) { - return true; - } - return false; +function createCapabilityCheck(capability: string) { + return (capabilities: Capabilities) => hasCapability(capabilities, capability); } -export function hasDeleteAlertsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['alerting:delete']) { - return true; - } - return false; -} - -export function hasDeleteActionsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['actions:delete']) { - return true; - } - return false; -} +export const hasShowAlertsCapability = createCapabilityCheck('alerting:show'); +export const hasShowActionsCapability = createCapabilityCheck('actions:show'); +export const hasSaveAlertsCapability = createCapabilityCheck('alerting:save'); +export const hasSaveActionsCapability = createCapabilityCheck('actions:save'); +export const hasDeleteAlertsCapability = createCapabilityCheck('alerting:delete'); +export const hasDeleteActionsCapability = createCapabilityCheck('actions:delete'); diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 342401c4778d8..96645e856e418 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -23,3 +23,7 @@ export function plugin(ctx: PluginInitializerContext) { export { Plugin }; export * from './plugin'; + +export { TIME_UNITS } from './application/constants'; +export { getTimeUnitLabel } from './common/lib/get_time_unit_label'; +export { ForLastExpression } from './common/expression_items/for_the_last'; From 70625429199afc6f1b30a30d9630818905085161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Tue, 24 Mar 2020 10:20:51 +0000 Subject: [PATCH 068/179] [skip-ci] Fix CODEOWNERS paths for the Pulse team (#60944) --- .github/CODEOWNERS | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d48b29c89ece6..6519bf9c493f9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -150,8 +150,11 @@ # Pulse /packages/kbn-analytics/ @elastic/pulse /src/legacy/core_plugins/ui_metric/ @elastic/pulse +/src/plugins/telemetry/ @elastic/pulse +/src/plugins/telemetry_collection_manager/ @elastic/pulse +/src/plugins/telemetry_management_section/ @elastic/pulse /src/plugins/usage_collection/ @elastic/pulse -/x-pack/legacy/plugins/telemetry/ @elastic/pulse +/x-pack/plugins/telemetry_collection_xpack/ @elastic/pulse # Kibana Alerting Services /x-pack/legacy/plugins/alerting/ @elastic/kibana-alerting-services From 4dbcb3c0e99553af4306670ac4a980aad6db209b Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Tue, 24 Mar 2020 10:52:00 +0000 Subject: [PATCH 069/179] [Alerting] removes unimplemented buttons from Alert Details page (#60934) Removed the "Edit" and "View in Activity Log" buttons as they have not yet been implemented. --- .../components/alert_details.test.tsx | 60 +------------------ .../components/alert_details.tsx | 17 ------ 2 files changed, 1 insertion(+), 76 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index f025b0396f04d..d781e8b761845 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -8,16 +8,8 @@ import uuid from 'uuid'; import { shallow } from 'enzyme'; import { AlertDetails } from './alert_details'; import { Alert, ActionType } from '../../../../types'; -import { - EuiTitle, - EuiBadge, - EuiFlexItem, - EuiButtonEmpty, - EuiSwitch, - EuiBetaBadge, -} from '@elastic/eui'; +import { EuiTitle, EuiBadge, EuiFlexItem, EuiSwitch, EuiBetaBadge } from '@elastic/eui'; import { times, random } from 'lodash'; -import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { ViewInApp } from './view_in_app'; @@ -218,31 +210,6 @@ describe('alert_details', () => { }); describe('links', () => { - it('links to the Edit flyout', () => { - const alert = mockAlert(); - - const alertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, - defaultActionGroupId: 'default', - }; - - expect( - shallow( - - ).containsMatchingElement( - - - - ) - ).toBeTruthy(); - }); - it('links to the app that created the alert', () => { const alert = mockAlert(); @@ -260,31 +227,6 @@ describe('alert_details', () => { ).containsMatchingElement() ).toBeTruthy(); }); - - it('links to the activity log', () => { - const alert = mockAlert(); - - const alertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, - defaultActionGroupId: 'default', - }; - - expect( - shallow( - - ).containsMatchingElement( - - - - ) - ).toBeTruthy(); - }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx index 49e818ebc7ee4..1f55e61e9ee0d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx @@ -17,7 +17,6 @@ import { EuiBadge, EuiPage, EuiPageContentBody, - EuiButtonEmpty, EuiSwitch, EuiCallOut, EuiSpacer, @@ -87,25 +86,9 @@ export const AlertDetails: React.FunctionComponent = ({ - - - - - - - - - - From d31e5f524f13f1d8fd91cf1e5a716ecb5c6fb742 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 24 Mar 2020 12:34:43 +0100 Subject: [PATCH 070/179] [Uptime] Ml detection of duration anomalies (#59785) * add flyout * add state * update state * ad job * update * updat * add ml analyze button * update api * use differential colors for duration chart * remove duration chart gql * update type * type fix * fix tyoe * update translation * update test * update conflicts * update anomaly record * chart * added annotations * update error handling * update * update types * fixed types * fix types * update types * update * update * remove unnecessary change * remove unnecessary change * fix type * update * save * update pr * update tets * update job deletion * update * update tets * upadte tests * fix types * update title text * update types * fixed tests * update tests and types * updated types * fix PR feedback * unit test * update more types * update test and manage job * resolve conflicts * types * remove unnecessary change * revert ml code * revert ml code * fixed formatting issues pointed by pr feedback --- src/plugins/kibana_react/public/index.ts | 1 + .../uptime/common/constants/rest_api.ts | 6 + .../plugins/uptime/common/constants/ui.ts | 4 + .../connected/charts/monitor_duration.tsx | 63 ++++- .../connected/empty_state/empty_state.tsx | 4 +- .../monitor/status_bar_container.tsx | 25 +- .../duration_charts.test.tsx.snap | 2 + .../charts/__tests__/duration_charts.test.tsx | 4 +- .../functional/charts/annotation_tooltip.tsx | 54 ++++ .../functional/charts/duration_chart.tsx | 71 +++++- .../charts/duration_line_bar_list.tsx | 91 +++++++ .../__snapshots__/empty_state.test.tsx.snap | 30 +-- .../__tests__/empty_state.test.tsx | 17 +- .../functional/empty_state/empty_state.tsx | 3 +- .../empty_state/empty_state_error.tsx | 3 +- .../functional/monitor_list/translations.ts | 7 + .../{ => ping_list}/location_name.tsx | 0 .../functional/ping_list/ping_list.tsx | 2 +- .../confirm_delete.test.tsx.snap | 56 ++++ .../__snapshots__/license_info.test.tsx.snap | 73 ++++++ .../__snapshots__/ml_flyout.test.tsx.snap | 240 ++++++++++++++++++ .../ml_integerations.test.tsx.snap | 86 +++++++ .../__snapshots__/ml_job_link.test.tsx.snap | 82 ++++++ .../__snapshots__/ml_manage_job.test.tsx.snap | 90 +++++++ .../ml/__tests__/confirm_delete.test.tsx | 25 ++ .../ml/__tests__/license_info.test.tsx | 21 ++ .../ml/__tests__/ml_flyout.test.tsx | 113 +++++++++ .../ml/__tests__/ml_integerations.test.tsx | 30 +++ .../ml/__tests__/ml_job_link.test.tsx | 25 ++ .../ml/__tests__/ml_manage_job.test.tsx | 32 +++ .../monitor_details/ml/confirm_delete.tsx | 59 +++++ .../monitor_details/ml/license_info.tsx | 33 +++ .../monitor_details/ml/manage_ml_job.tsx | 80 ++++++ .../monitor_details/ml/ml_flyout.tsx | 86 +++++++ .../ml/ml_flyout_container.tsx | 154 +++++++++++ .../monitor_details/ml/ml_integeration.tsx | 111 ++++++++ .../monitor_details/ml/ml_job_link.tsx | 52 ++++ .../monitor_details/ml/translations.tsx | 150 +++++++++++ .../contexts/uptime_settings_context.tsx | 20 +- .../uptime/public/state/actions/index.ts | 1 + .../public/state/actions/index_status.ts | 2 +- .../uptime/public/state/actions/ml_anomaly.ts | 46 ++++ .../public/state/actions/monitor_duration.ts | 5 +- .../uptime/public/state/actions/types.ts | 30 ++- .../uptime/public/state/actions/utils.ts | 14 +- .../uptime/public/state/api/ml_anomaly.ts | 88 +++++++ ...th_effect.test.ts => fetch_effect.test.ts} | 36 ++- .../public/state/effects/fetch_effect.ts | 8 +- .../uptime/public/state/effects/index.ts | 2 + .../uptime/public/state/effects/ml_anomaly.ts | 53 ++++ .../uptime/public/state/reducers/index.ts | 2 + .../public/state/reducers/index_status.ts | 14 +- .../public/state/reducers/ml_anomaly.ts | 71 ++++++ .../public/state/reducers/monitor_duration.ts | 6 +- .../uptime/public/state/reducers/types.ts | 7 +- .../uptime/public/state/reducers/utils.ts | 33 ++- .../state/selectors/__tests__/index.test.ts | 22 +- .../uptime/public/state/selectors/index.ts | 41 ++- x-pack/package.json | 2 +- x-pack/plugins/ml/common/types/anomalies.ts | 17 ++ .../ml/common/types/data_recognizer.ts | 17 ++ .../models/data_recognizer/data_recognizer.ts | 13 +- .../modules/uptime_heartbeat/logo.json | 3 + .../modules/uptime_heartbeat/manifest.json | 26 ++ .../ml/datafeed_high_latency_by_geo.json | 13 + .../ml/high_latency_by_geo.json | 29 +++ .../build_anomaly_table_items.d.ts | 19 +- .../models/results_service/results_service.ts | 4 +- yarn.lock | 73 +++++- 69 files changed, 2522 insertions(+), 180 deletions(-) create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/charts/annotation_tooltip.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_line_bar_list.tsx rename x-pack/legacy/plugins/uptime/public/components/functional/{ => ping_list}/location_name.tsx (100%) create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/confirm_delete.test.tsx.snap create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/license_info.test.tsx.snap create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_integerations.test.tsx.snap create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_job_link.test.tsx.snap create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_manage_job.test.tsx.snap create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/confirm_delete.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/license_info.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/ml_flyout.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/ml_integerations.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/ml_job_link.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/ml_manage_job.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/confirm_delete.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/license_info.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/manage_ml_job.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_flyout.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_flyout_container.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_integeration.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_job_link.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/translations.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/state/actions/ml_anomaly.ts create mode 100644 x-pack/legacy/plugins/uptime/public/state/api/ml_anomaly.ts rename x-pack/legacy/plugins/uptime/public/state/effects/__tests__/{fecth_effect.test.ts => fetch_effect.test.ts} (67%) create mode 100644 x-pack/legacy/plugins/uptime/public/state/effects/ml_anomaly.ts create mode 100644 x-pack/legacy/plugins/uptime/public/state/reducers/ml_anomaly.ts create mode 100644 x-pack/plugins/ml/common/types/data_recognizer.ts create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/uptime_heartbeat/logo.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/uptime_heartbeat/manifest.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/uptime_heartbeat/ml/datafeed_high_latency_by_geo.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/uptime_heartbeat/ml/high_latency_by_geo.json diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index e88ca7178cde3..e1689e38dbfe0 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -26,6 +26,7 @@ export * from './field_icon'; export * from './table_list_view'; export * from './split_panel'; export { ValidatedDualRange } from './validated_range'; +export * from './notifications'; export { Markdown, MarkdownSimple } from './markdown'; export { reactToUiComponent, uiToReactComponent } from './adapters'; export { useUrlTracker } from './use_url_tracker'; diff --git a/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts b/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts index 61197d6dc373d..a1a3e86e6a97e 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts @@ -16,4 +16,10 @@ export enum API_URLS { PING_HISTOGRAM = `/api/uptime/ping/histogram`, SNAPSHOT_COUNT = `/api/uptime/snapshot/count`, FILTERS = `/api/uptime/filters`, + + ML_MODULE_JOBS = `/api/ml/modules/jobs_exist/`, + ML_SETUP_MODULE = '/api/ml/modules/setup/', + ML_DELETE_JOB = `/api/ml/jobs/delete_jobs`, + ML_CAPABILITIES = '/api/ml/ml_capabilities', + ML_ANOMALIES_RESULT = `/api/ml/results/anomalies_table_data`, } diff --git a/x-pack/legacy/plugins/uptime/common/constants/ui.ts b/x-pack/legacy/plugins/uptime/common/constants/ui.ts index 8d223dbbba556..29e8dabf53f92 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/ui.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/ui.ts @@ -15,6 +15,10 @@ export enum STATUS { DOWN = 'down', } +export const ML_JOB_ID = 'high_latency_by_geo'; + +export const ML_MODULE_ID = 'uptime_heartbeat'; + export const UNNAMED_LOCATION = 'Unnamed-location'; export const SHORT_TS_LOCALE = 'en-short-locale'; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/charts/monitor_duration.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/charts/monitor_duration.tsx index 8d2b8d2cd8e0d..7d1cb08cb8b1c 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/charts/monitor_duration.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/charts/monitor_duration.tsx @@ -7,10 +7,21 @@ import React, { useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useUrlParams } from '../../../hooks'; -import { getMonitorDurationAction } from '../../../state/actions'; +import { + getAnomalyRecordsAction, + getMLCapabilitiesAction, + getMonitorDurationAction, +} from '../../../state/actions'; import { DurationChartComponent } from '../../functional/charts'; -import { selectDurationLines } from '../../../state/selectors'; +import { + anomaliesSelector, + hasMLFeatureAvailable, + hasMLJobSelector, + selectDurationLines, +} from '../../../state/selectors'; import { UptimeRefreshContext } from '../../../contexts'; +import { getMLJobId } from '../../../state/api/ml_anomaly'; +import { JobStat } from '../../../../../../../plugins/ml/common/types/data_recognizer'; interface Props { monitorId: string; @@ -18,24 +29,58 @@ interface Props { export const DurationChart: React.FC = ({ monitorId }: Props) => { const [getUrlParams] = useUrlParams(); - const { dateRangeStart, dateRangeEnd } = getUrlParams(); + const { + dateRangeStart, + dateRangeEnd, + absoluteDateRangeStart, + absoluteDateRangeEnd, + } = getUrlParams(); - const { monitor_duration, loading } = useSelector(selectDurationLines); + const { durationLines, loading } = useSelector(selectDurationLines); + + const isMLAvailable = useSelector(hasMLFeatureAvailable); + + const { data: mlJobs, loading: jobsLoading } = useSelector(hasMLJobSelector); + + const hasMLJob = + !!mlJobs?.jobsExist && + !!mlJobs.jobs.find((job: JobStat) => job.id === getMLJobId(monitorId as string)); + + const anomalies = useSelector(anomaliesSelector); const dispatch = useDispatch(); const { lastRefresh } = useContext(UptimeRefreshContext); useEffect(() => { - dispatch( - getMonitorDurationAction({ monitorId, dateStart: dateRangeStart, dateEnd: dateRangeEnd }) - ); + if (isMLAvailable) { + const anomalyParams = { + listOfMonitorIds: [monitorId], + dateStart: absoluteDateRangeStart, + dateEnd: absoluteDateRangeEnd, + }; + + dispatch(getAnomalyRecordsAction.get(anomalyParams)); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dateRangeStart, dateRangeEnd, dispatch, lastRefresh, monitorId, isMLAvailable]); + + useEffect(() => { + const params = { monitorId, dateStart: dateRangeStart, dateEnd: dateRangeEnd }; + dispatch(getMonitorDurationAction(params)); }, [dateRangeStart, dateRangeEnd, dispatch, lastRefresh, monitorId]); + useEffect(() => { + dispatch(getMLCapabilitiesAction.get()); + }, [dispatch]); + return ( ); }; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/empty_state/empty_state.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/empty_state/empty_state.tsx index cac7042ca5b5c..b383a696095a3 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/empty_state/empty_state.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/empty_state/empty_state.tsx @@ -11,7 +11,7 @@ import { indexStatusSelector } from '../../../state/selectors'; import { EmptyStateComponent } from '../../functional/empty_state/empty_state'; export const EmptyState: React.FC = ({ children }) => { - const { data, loading, errors } = useSelector(indexStatusSelector); + const { data, loading, error } = useSelector(indexStatusSelector); const dispatch = useDispatch(); @@ -23,7 +23,7 @@ export const EmptyState: React.FC = ({ children }) => { ); diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx index 456fa2b30bca8..9e7834ae6f242 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx @@ -22,7 +22,8 @@ interface StateProps { } interface DispatchProps { - loadMonitorStatus: (dateStart: string, dateEnd: string, monitorId: string) => void; + loadMonitorStatus: typeof getMonitorStatusAction; + loadSelectedMonitor: typeof getSelectedMonitorAction; } interface OwnProps { @@ -33,6 +34,7 @@ type Props = OwnProps & StateProps & DispatchProps; const Container: React.FC = ({ loadMonitorStatus, + loadSelectedMonitor, monitorId, monitorStatus, monitorLocations, @@ -43,8 +45,9 @@ const Container: React.FC = ({ const { dateRangeStart: dateStart, dateRangeEnd: dateEnd } = getUrlParams(); useEffect(() => { - loadMonitorStatus(dateStart, dateEnd, monitorId); - }, [monitorId, dateStart, dateEnd, loadMonitorStatus, lastRefresh]); + loadMonitorStatus({ dateStart, dateEnd, monitorId }); + loadSelectedMonitor({ monitorId }); + }, [monitorId, dateStart, dateEnd, loadMonitorStatus, lastRefresh, loadSelectedMonitor]); return ( ({ }); const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ - loadMonitorStatus: (dateStart: string, dateEnd: string, monitorId: string) => { - dispatch( - getMonitorStatusAction({ - monitorId, - dateStart, - dateEnd, - }) - ); - dispatch( - getSelectedMonitorAction({ - monitorId, - }) - ); - }, + loadSelectedMonitor: params => dispatch(getSelectedMonitorAction(params)), + loadMonitorStatus: params => dispatch(getMonitorStatusAction(params)), }); // @ts-ignore TODO: Investigate typescript issues here diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/duration_charts.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/duration_charts.test.tsx.snap index 1e2d2b9144416..6c38f3e338cfd 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/duration_charts.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/duration_charts.test.tsx.snap @@ -52,6 +52,8 @@ exports[`MonitorCharts component renders the component without errors 1`] = ` } > { it('renders the component without errors', () => { const component = shallowWithRouter( ); expect(component).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/annotation_tooltip.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/annotation_tooltip.tsx new file mode 100644 index 0000000000000..ad2a6d02c5364 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/annotation_tooltip.tsx @@ -0,0 +1,54 @@ +/* + * 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 from 'react'; +import moment from 'moment'; +import styled from 'styled-components'; +import { FormattedMessage } from '@kbn/i18n/react'; + +const Header = styled.div` + font-weight: bold; + padding-left: 4px; +`; + +const RecordSeverity = styled.div` + font-weight: bold; + border-left: 4px solid ${props => props.color}; + padding-left: 2px; +`; + +const TimeDiv = styled.div` + font-weight: 500; + border-bottom: 1px solid gray; + padding-bottom: 2px; +`; + +export const AnnotationTooltip = ({ details }: { details: string }) => { + const data = JSON.parse(details); + + function capitalizeFirstLetter(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + return ( + <> + {moment(data.time).format('lll')} +
+ +
+ + + + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx index 6bd4e7431f97a..d149e7a6deb5a 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx @@ -4,12 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Axis, Chart, Position, timeFormatter, Settings } from '@elastic/charts'; -import { EuiPanel, EuiTitle } from '@elastic/eui'; -import React from 'react'; +import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui'; +import { Axis, Chart, Position, timeFormatter, Settings } from '@elastic/charts'; +import { SeriesIdentifier } from '@elastic/charts/dist/chart_types/xy_chart/utils/series'; import { getChartDateLabel } from '../../../lib/helper'; import { LocationDurationLine } from '../../../../common/types'; import { DurationLineSeriesList } from './duration_line_series_list'; @@ -17,6 +18,9 @@ import { ChartWrapper } from './chart_wrapper'; import { useUrlParams } from '../../../hooks'; import { getTickFormat } from './get_tick_format'; import { ChartEmptyState } from './chart_empty_state'; +import { DurationAnomaliesBar } from './duration_line_bar_list'; +import { MLIntegrationComponent } from '../../monitor_details/ml/ml_integeration'; +import { AnomalyRecords } from '../../../state/actions'; interface DurationChartProps { /** @@ -29,6 +33,10 @@ interface DurationChartProps { * To represent the loading spinner on chart */ loading: boolean; + + hasMLJob: boolean; + + anomalies: AnomalyRecords | null; } /** @@ -37,29 +45,64 @@ interface DurationChartProps { * milliseconds. * @param props The props required for this component to render properly */ -export const DurationChartComponent = ({ locationDurationLines, loading }: DurationChartProps) => { +export const DurationChartComponent = ({ + locationDurationLines, + anomalies, + loading, + hasMLJob, +}: DurationChartProps) => { const hasLines = locationDurationLines.length > 0; const [getUrlParams, updateUrlParams] = useUrlParams(); const { absoluteDateRangeStart: min, absoluteDateRangeEnd: max } = getUrlParams(); + const [hiddenLegends, setHiddenLegends] = useState([]); + const onBrushEnd = (minX: number, maxX: number) => { updateUrlParams({ dateRangeStart: moment(minX).toISOString(), dateRangeEnd: moment(maxX).toISOString(), }); }; + + const legendToggleVisibility = (legendItem: SeriesIdentifier | null) => { + if (legendItem) { + setHiddenLegends(prevState => { + if (prevState.includes(legendItem.specId)) { + return [...prevState.filter(item => item !== legendItem.specId)]; + } else { + return [...prevState, legendItem.specId]; + } + }); + } + }; + return ( <> - -

- -

-
+ + + +

+ {hasMLJob ? ( + + ) : ( + + )} +

+
+
+ + + +
+ {hasLines ? ( @@ -69,6 +112,7 @@ export const DurationChartComponent = ({ locationDurationLines, loading }: Durat showLegendExtra legendPosition={Position.Bottom} onBrushEnd={onBrushEnd} + onLegendItemClick={legendToggleVisibility} /> + ) : ( { + const anomalyAnnotations: Map = new Map(); + + Object.keys(ANOMALY_SEVERITY).forEach(severityLevel => { + anomalyAnnotations.set(severityLevel.toLowerCase(), { rect: [], color: '' }); + }); + + if (anomalies?.anomalies) { + const records = anomalies.anomalies; + records.forEach((record: any) => { + let recordObsvLoc = record.source['observer.geo.name']?.[0] ?? 'N/A'; + if (recordObsvLoc === '') { + recordObsvLoc = 'N/A'; + } + if (hiddenLegends.length && hiddenLegends.includes(`loc-avg-${recordObsvLoc}`)) { + return; + } + const severityLevel = getSeverityType(record.severity); + + const tooltipData = { + time: record.source.timestamp, + score: record.severity, + severity: severityLevel, + color: getSeverityColor(record.severity), + }; + + const anomalyRect = { + coordinates: { + x0: moment(record.source.timestamp).valueOf(), + x1: moment(record.source.timestamp) + .add(record.source.bucket_span, 's') + .valueOf(), + }, + details: JSON.stringify(tooltipData), + }; + anomalyAnnotations.get(severityLevel)!.rect.push(anomalyRect); + anomalyAnnotations.get(severityLevel)!.color = getSeverityColor(record.severity); + }); + } + + const getRectStyle = (color: string) => { + return { + fill: color, + opacity: 1, + strokeWidth: 2, + stroke: color, + }; + }; + + const tooltipFormatter: AnnotationTooltipFormatter = (details?: string) => { + return ; + }; + + return ( + <> + {Array.from(anomalyAnnotations).map(([keyIndex, rectAnnotation]) => { + return rectAnnotation.rect.length > 0 ? ( + + ) : null; + })} + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap index 5548189175c55..2d45bbd18a60c 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap @@ -749,17 +749,7 @@ exports[`EmptyState component renders error message when an error occurs 1`] = ` @@ -904,7 +884,7 @@ exports[`EmptyState component renders error message when an error occurs 1`] = ` body={

- An error occurred + There was an error fetching your data.

} @@ -971,9 +951,9 @@ exports[`EmptyState component renders error message when an error occurs 1`] = ` className="euiText euiText--medium" >

- An error occurred + There was an error fetching your data.

diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx index 20113df3010f8..a74ad543c3318 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx @@ -7,8 +7,9 @@ import React from 'react'; import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; import { EmptyStateComponent } from '../empty_state'; -import { GraphQLError } from 'graphql'; import { StatesIndexStatus } from '../../../../../common/runtime_types'; +import { IHttpFetchError } from '../../../../../../../../../target/types/core/public/http'; +import { HttpFetchError } from '../../../../../../../../../src/core/public/http/http_fetch_error'; describe('EmptyState component', () => { let statesIndexStatus: StatesIndexStatus; @@ -41,18 +42,8 @@ describe('EmptyState component', () => { }); it(`renders error message when an error occurs`, () => { - const errors: GraphQLError[] = [ - { - message: 'An error occurred', - locations: undefined, - path: undefined, - nodes: undefined, - source: undefined, - positions: undefined, - originalError: undefined, - extensions: undefined, - name: 'foo', - }, + const errors: IHttpFetchError[] = [ + new HttpFetchError('There was an error fetching your data.', 'error', {} as any), ]; const component = mountWithIntl( diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx index 80afc2894ea44..ae6a1b892bc99 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx @@ -10,12 +10,13 @@ import { EmptyStateError } from './empty_state_error'; import { EmptyStateLoading } from './empty_state_loading'; import { DataMissing } from './data_missing'; import { StatesIndexStatus } from '../../../../common/runtime_types'; +import { IHttpFetchError } from '../../../../../../../../target/types/core/public/http'; interface EmptyStateProps { children: JSX.Element[] | JSX.Element; statesIndexStatus: StatesIndexStatus | null; loading: boolean; - errors?: Error[]; + errors?: IHttpFetchError[]; } export const EmptyStateComponent = ({ diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state_error.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state_error.tsx index c8e2bece1cb7f..1135b969018a1 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state_error.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state_error.tsx @@ -7,9 +7,10 @@ import { EuiEmptyPrompt, EuiPanel, EuiTitle, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { Fragment } from 'react'; +import { IHttpFetchError } from '../../../../../../../../target/types/core/public/http'; interface EmptyStateErrorProps { - errors: Error[]; + errors: IHttpFetchError[]; } export const EmptyStateError = ({ errors }: EmptyStateErrorProps) => { diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/translations.ts b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/translations.ts index 5252d90215e95..7b9b2d07f2a76 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/translations.ts +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/translations.ts @@ -64,3 +64,10 @@ export const UP = i18n.translate('xpack.uptime.monitorList.statusColumn.upLabel' export const DOWN = i18n.translate('xpack.uptime.monitorList.statusColumn.downLabel', { defaultMessage: 'Down', }); + +export const RESPONSE_ANOMALY_SCORE = i18n.translate( + 'xpack.uptime.monitorList.anomalyColumn.label', + { + defaultMessage: 'Response Anomaly Score', + } +); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_name.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/location_name.tsx similarity index 100% rename from x-pack/legacy/plugins/uptime/public/components/functional/location_name.tsx rename to x-pack/legacy/plugins/uptime/public/components/functional/ping_list/location_name.tsx diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx index e8825dacc0078..d245bc1456e6a 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx @@ -29,7 +29,7 @@ import { Ping, PingResults } from '../../../../common/graphql/types'; import { convertMicrosecondsToMilliseconds as microsToMillis } from '../../../lib/helper'; import { UptimeGraphQLQueryProps, withUptimeGraphQL } from '../../higher_order'; import { pingsQuery } from '../../../queries'; -import { LocationName } from './../location_name'; +import { LocationName } from './location_name'; import { Pagination } from './../monitor_list'; import { PingListExpandedRowComponent } from './expanded_row'; diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/confirm_delete.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/confirm_delete.test.tsx.snap new file mode 100644 index 0000000000000..24ef7eda0d129 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/confirm_delete.test.tsx.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ML Confirm Job Delete shallow renders without errors 1`] = ` + + +

+ +

+

+ +

+
+
+`; + +exports[`ML Confirm Job Delete shallow renders without errors while loading 1`] = ` + + +

+ + ) +

+ +
+
+`; diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/license_info.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/license_info.test.tsx.snap new file mode 100644 index 0000000000000..2457488c4facc --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/license_info.test.tsx.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ShowLicenseInfo renders without errors 1`] = ` +Array [ +
+
+ +
+

+ In order to access duration anomaly detection, you have to be subscribed to an Elastic Platinum license. +

+ + + + Start free 14-day trial + + + +
+
, +
, +] +`; + +exports[`ShowLicenseInfo shallow renders without errors 1`] = ` + + +

+ In order to access duration anomaly detection, you have to be subscribed to an Elastic Platinum license. +

+ + Start free 14-day trial + +
+ +
+`; diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap new file mode 100644 index 0000000000000..354521e7c55b9 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap @@ -0,0 +1,240 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ML Flyout component renders without errors 1`] = ` + + + +

+ Enable anomaly detection +

+
+ +
+ + + +

+ Here you can create a machine learning job to calculate anomaly scores on + response durations for Uptime Monitor. Once enabled, the monitor duration chart on the details page + will show the expected bounds and annotate the graph with anomalies. You can also potentially + identify periods of increased latency across geographical regions. +

+

+ + Machine Learning jobs management page + , + } + } + /> +

+

+ + Note: It might take a few minutes for the job to begin calculating results. + +

+
+ +
+ + + + + Create new job + + + + +
+`; + +exports[`ML Flyout component shows license info if no ml available 1`] = ` +
+
+
+
+