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 = () => {
- {!showEmptyPrompt && (
+ {hasValidLicense && !showEmptyPrompt && (
@@ -77,13 +83,24 @@ export const CustomLinkOverview = () => {
-
- {showEmptyPrompt ? (
-
+ {hasValidLicense ? (
+ showEmptyPrompt ? (
+
+ ) : (
+
+ )
) : (
-
)}
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 (
-
+
);
},
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 = (
+
+ {i18n.translate('xpack.apm.license.title', {
+ defaultMessage: 'Start free 30-day trial'
+ })}
+
+ }
+ body={{text}
}
+ actions={
+
+ {i18n.translate('xpack.apm.license.button', {
+ defaultMessage: 'Start trial'
+ })}
+
+ }
+ />
+ );
+
+ const renderWithBetaBadge = (
+
+ {renderLicenseBody}
+
+ );
+
+ 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 (
-
+
);
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(
+
+ );
+ expectTextsInDocument(component, ['CUSTOM LINKS', 'Create', 'foo', 'bar']);
+ });
+
+ it('closes popover', () => {
+ const handleCloseMock = jest.fn();
+ const { getByText } = render(
+
+ );
+ 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(
+
+ );
+ 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 (
+ <>
+
+
+
+
+ {i18n.translate(
+ 'xpack.apm.transactionActionMenu.customLink.popover.title',
+ {
+ defaultMessage: 'CUSTOM LINKS'
+ }
+ )}
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
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(
+
+ );
+ expectTextsInDocument(component, ['foo', 'bar']);
+ });
+
+ it('doesnt show any links', () => {
+ const component = render(
+
+ );
+ 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;
+}) => (
+
+ {customLinks.map(link => {
+ let href = link.url;
+ try {
+ href = Mustache.render(link.url, transaction);
+ } catch (e) {
+ // ignores any error that happens
+ }
+ return (
+
+ );
+ })}
+
+);
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(
+
+ );
+ expect(
+ component.getByLabelText('Custom links settings page')
+ ).toBeInTheDocument();
+ expectTextsInDocument(component, ['Create']);
+ });
+ it('renders without create button', () => {
+ const component = render(
+
+ );
+ 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(
+
+ );
+ 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;
+}) => (
+
+
+
+
+
+
+
+
+
+
+ {showCreateCustomLinkButton && (
+
+
+ {i18n.translate('xpack.apm.customLink.buttom.create.title', {
+ defaultMessage: 'Create'
+ })}
+
+
+ )}
+
+
+
+);
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(
+
+ );
+
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+
+ 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(
+
+ );
+ 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 = (
+ <>
+
+ {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.'
+ })}
+
+
+
+ {i18n.translate('xpack.apm.customLink.buttom.create', {
+ defaultMessage: 'Create custom link'
+ })}
+
+ >
+ );
+
+ const renderCustomLinkBottomSection = isEmpty(customLinks) ? (
+ renderEmptyPrompt
+ ) : (
+ 3}>
+
+ {i18n.translate('xpack.apm.transactionActionMenu.customLink.seeMore', {
+ defaultMessage: 'See more'
+ })}
+
+
+
+ );
+
+ return (
+ <>
+
+
+
+
+
+ {i18n.translate(
+ 'xpack.apm.transactionActionMenu.customLink.section',
+ {
+ defaultMessage: 'Custom Links'
+ }
+ )}
+
+
+
+
+
+
+
+
+
+ {i18n.translate('xpack.apm.transactionActionMenu.customLink.subtitle', {
+ defaultMessage: 'Links will open in a new window.'
+ })}
+
+
+
+ {status === FETCH_STATUS.LOADING ? (
+
+ ) : (
+ 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 = ({
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 = ({
urlParams
});
+ const toggleCustomLinkFlyout = () => {
+ setIsCustomLinkFlyoutOpen(isOpen => !isOpen);
+ };
+
+ const toggleCustomLinkPopover = () => {
+ setIsCustomLinksPopoverOpen(isOpen => !isOpen);
+ };
+
return (
-
+ <>
+ {isCustomLinkFlyoutOpen && (
+ {
+ toggleCustomLinkFlyout();
+ refetch();
+ }}
+ onDelete={() => {
+ toggleCustomLinkFlyout();
+ refetch();
+ }}
+ />
+ )}
+
+ >
);
};
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) => {
const rendered = render(
@@ -23,6 +30,15 @@ const renderTransaction = async (transaction: Record) => {
};
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(
+
+
+
+
+
+ );
+ 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(
+
+
+
+
+
+ );
+ 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(
+
+
+
+
+
+ );
+ 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(
+
+
+
+
+
+ );
+ 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;
+
+export const FILTER_OPTIONS: ReadonlyArray = [
+ 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;
};
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(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(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;
-
-export const filterOptions: Array = [
- 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
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
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
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
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
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
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 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
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
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
Co-authored-by: Elastic Machine
Co-authored-by: Henry Harding
---
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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
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 [
+ setFlyoutVisible(true)}>
+
+ ,
+
+
+ ,
+ ];
+ }, [kibana.services]);
+
+ return (
+ <>
+
+
+
+ }
+ isOpen={popoverOpen}
+ closePopover={closePopover}
+ >
+
+
+
+ >
+ );
+};
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;
+ series?: MetricsExplorerSeries;
+ setVisible: React.Dispatch>;
+}
+
+export const AlertFlyout = (props: Props) => {
+ const { triggersActionsUI } = useContext(TriggerActionsContext);
+ const { services } = useKibana();
+
+ return (
+ <>
+ {triggersActionsUI && (
+
+
+
+ )}
+ >
+ );
+};
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;
+ series?: MetricsExplorerSeries;
+}
+
+interface Props {
+ errors: IErrorObject[];
+ alertParams: {
+ criteria: MetricExpression[];
+ groupBy?: string;
+ filterQuery?: string;
+ };
+ alertsContext: AlertsContextValue;
+ 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 => {
+ const { setAlertParams, alertParams, errors, alertsContext } = props;
+ const { source, createDerivedIndexPattern } = useSource({ sourceId: 'default' });
+ const [timeSize, setTimeSize] = useState(1);
+ const [timeUnit, setTimeUnit] = useState('s');
+
+ const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [
+ createDerivedIndexPattern,
+ ]);
+
+ const options = useMemo(() => {
+ if (alertsContext.metadata?.currentOptions?.metrics) {
+ return alertsContext.metadata.currentOptions as MetricsExplorerOptions;
+ } else {
+ return {
+ metrics: [],
+ aggregation: 'avg',
+ };
+ }
+ }, [alertsContext.metadata]);
+
+ const defaultExpression = useMemo(
+ () => ({
+ 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 (
+ <>
+
+
+
+
+
+
+
+ {alertParams.criteria &&
+ alertParams.criteria.map((e, idx) => {
+ return (
+ 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 || {}}
+ />
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {alertsContext.metadata && (
+
+
+
+ )}
+ >
+ );
+};
+
+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 = 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 (
+ <>
+
+
+
+
+
+
+ {aggType !== 'count' && (
+
+ ({
+ normalizedType: f.type,
+ name: f.name,
+ }))}
+ aggType={aggType}
+ errors={errors}
+ onChangeSelectedAggField={updateMetric}
+ />
+
+ )}
+
+ '}
+ threshold={threshold}
+ onChangeSelectedThresholdComparator={updateComparator}
+ onChangeSelectedThreshold={updateThreshold}
+ errors={errors}
+ />
+
+
+
+ {canDelete && (
+
+ remove(expressionId)}
+ />
+
+ )}
+
+
+ >
+ );
+};
+
+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 = ({
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 = ({
]
: [];
- 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 = ({
{actionLabel}
);
+
return (
-
-
-
+ <>
+
+
+
+
+ >
);
};
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(value || '');
const [isValid, setValidation] = useState(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 (
@@ -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 (
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 = ({
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 = ({
};
return (
-
-
-
-
-
-
- {inventoryId.label && (
-
-
-
-
-
- )}
-
-
-
-
-
-
-
-
-
+ <>
+
+
+
+
+
+
+ {inventoryId.label && (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ >
);
};
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 (
@@ -59,31 +62,38 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => {
defaultMessage: 'Metrics',
})}
>
-
+
+
+
+
+
+
+
+
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({
+ triggersActionsUI: null,
+});
+
+interface Props {
+ triggersActionsUI: TriggersAndActionsUIPublicPluginSetup;
+}
+
+export const TriggersActionsProvider: React.FC = props => {
+ return (
+
+ {props.children}
+
+ );
+};
From 3401ae42e0b9d700a91b6933f3310b61ee19789e Mon Sep 17 00:00:00 2001
From: Luke Elmers
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 {
- 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: () => ,
- };
-});
-
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: () => ,
- };
-});
-
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: () => ,
- };
-});
-
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
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
---
.../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 {
{
@@ -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
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' ? (
{
closePopover();
updateCaseStatus('closed');
@@ -39,8 +40,9 @@ export const getBulkItems = ({
) : (
{
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(({ 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(({ 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
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
---
.../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;
+ createReindexOperation(
+ indexName: string,
+ opts?: { enqueue?: boolean }
+ ): Promise;
/**
* 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;
+ resumeReindexOperation(
+ indexName: string,
+ opts?: { enqueue?: boolean }
+ ): Promise;
+
+ /**
+ * 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;
/**
* 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
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 | "geo_point"
| |
| GEO\_SHAPE | "geo_shape"
| |
| HALF\_FLOAT | "half_float"
| |
+| HISTOGRAM | "histogram"
| |
| INTEGER | "integer"
| |
| IP | "ip"
| |
| KEYWORD | "keyword"
| |
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 | "date"
| |
| GEO\_POINT | "geo_point"
| |
| GEO\_SHAPE | "geo_shape"
| |
+| HISTOGRAM | "histogram"
| |
| IP | "ip"
| |
| MURMUR3 | "murmur3"
| |
| NESTED | "nested"
| |
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 | "geo_point"
| |
| GEO\_SHAPE | "geo_shape"
| |
| HALF\_FLOAT | "half_float"
| |
+| HISTOGRAM | "histogram"
| |
| INTEGER | "integer"
| |
| IP | "ip"
| |
| KEYWORD | "keyword"
| |
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 | "date"
| |
| GEO\_POINT | "geo_point"
| |
| GEO\_SHAPE | "geo_shape"
| |
+| HISTOGRAM | "histogram"
| |
| IP | "ip"
| |
| MURMUR3 | "murmur3"
| |
| NESTED | "nested"
| |
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({
{
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 => {
+ 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
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
* 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
Co-Authored-By: Mike Côté
* Using new classes for changing password
* Removing unneeded asScoped call
Co-authored-by: Aleh Zasypkin
Co-authored-by: Elastic Machine
Co-authored-by: Mike Côté
---
.../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;
@@ -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;
+ 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
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;
type EventArg = Event | React.MouseEvent;
-export const AnomalyThresholdSlider: React.FC = ({ field }) => {
+export const AnomalyThresholdSlider: React.FC = ({
+ describedByIds = [],
+ field,
+}) => {
const threshold = field.value as number;
const onThresholdChange = useCallback(
(event: EventArg) => {
@@ -26,7 +30,12 @@ export const AnomalyThresholdSlider: React.FC = ({
);
return (
-
+
= ({ field }) => {
+export const MlJobSelect: React.FC = ({ 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 = ({ field }) => {
}));
return (
-
+
(
+
+ {hasValidLicense ? (
+ i18n.ML_TYPE_DESCRIPTION
+ ) : (
+
+
+
+ ),
+ }}
+ />
+ )}
+
+);
+
interface SelectRuleTypeProps {
+ describedByIds?: string[];
field: FieldHook;
- isReadOnly: boolean;
+ hasValidLicense?: boolean;
+ isReadOnly?: boolean;
}
-export const SelectRuleType: React.FC = ({ field, isReadOnly = false }) => {
+export const SelectRuleType: React.FC = ({
+ 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 = ({ field, isReadOnl
);
const setMl = useCallback(() => setType('machine_learning'), [setType]);
const setQuery = useCallback(() => setType('query'), [setType]);
- const license = true; // TODO
+ const mlCardDisabled = isReadOnly || !hasValidLicense;
return (
-
+
= ({ field, isReadOnl
}
icon={}
+ 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 = ({
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 = ({
path="ruleType"
component={SelectRuleType}
componentProps={{
+ describedByIds: ['detectionEngineStepDefineRuleType'],
+ hasValidLicense: mlCapabilities.isPlatinumOrTrialLicense,
isReadOnly: isUpdateView,
}}
/>
@@ -220,7 +224,6 @@ const StepDefineRuleComponent: FC = ({
component={QueryBarDefineRule}
componentProps={{
browserFields,
- loading: indexPatternLoadingQueryBar,
idAria: 'detectionEngineStepDefineRuleQueryBar',
indexPattern: indexPatternQueryBar,
isDisabled: isLoading,
@@ -234,8 +237,20 @@ const StepDefineRuleComponent: FC = ({
<>
-
-
+
+
>
From 91e8e3e883d51634a6b958c0ccd8d0011fdc5559 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?=
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
---
.../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 {
+ 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
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
---
.../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: (
),
},
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 (
+ {datafeedRunning && (
+ <>
+
+
+
+
+ >
+ )}
@@ -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 (
@@ -152,6 +153,14 @@ export class JobDetails extends Component {
defaultMessage="Model memory limit"
/>
}
+ helpText={
+ datafeedRunning ? (
+
+ ) : null
+ }
isInvalid={mmlValidationError !== ''}
error={mmlValidationError}
>
@@ -160,6 +169,7 @@ export class JobDetails extends Component {
onChange={this.onMmlChange}
isInvalid={mmlValidationError !== ''}
error={mmlValidationError}
+ disabled={datafeedRunning}
/>
@@ -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
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
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
---
.../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;
+ 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
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;
+ /**
+ * Delete reindex operations for completed indices with deprecations.
+ * @param indexNames
+ */
+ cleanupReindexOperations(indexNames: string[]): Promise | 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
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
---
.../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
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
z
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