;
@@ -59,6 +60,7 @@ function createKibanaRequestMock({
method = 'get',
socket = new Socket(),
routeTags,
+ routeAuthRequired,
validation = {},
}: RequestFixtureOptions
= {}) {
const queryString = stringify(query, { sort: false });
@@ -77,7 +79,9 @@ function createKibanaRequestMock
({
query: queryString,
search: queryString ? `?${queryString}` : queryString,
},
- route: { settings: { tags: routeTags } },
+ route: {
+ settings: { tags: routeTags, auth: routeAuthRequired },
+ },
raw: {
req: { socket },
},
diff --git a/src/plugins/usage_collection/server/mocks.ts b/src/plugins/usage_collection/server/mocks.ts
new file mode 100644
index 0000000000000..2194b1fb83f6e
--- /dev/null
+++ b/src/plugins/usage_collection/server/mocks.ts
@@ -0,0 +1,38 @@
+/*
+ * 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 { loggingServiceMock } from '../../../core/server/mocks';
+import { UsageCollectionSetup } from './plugin';
+import { CollectorSet } from './collector';
+
+const createSetupContract = () => {
+ return {
+ ...new CollectorSet({
+ logger: loggingServiceMock.createLogger(),
+ maximumWaitTimeForAllCollectorsInS: 1,
+ }),
+ registerLegacySavedObjects: jest.fn() as jest.Mocked<
+ UsageCollectionSetup['registerLegacySavedObjects']
+ >,
+ } as UsageCollectionSetup;
+};
+
+export const usageCollectionPluginMock = {
+ createSetupContract,
+};
diff --git a/x-pack/legacy/plugins/spaces/index.ts b/x-pack/legacy/plugins/spaces/index.ts
index ab3388ae96475..757c1eb557c54 100644
--- a/x-pack/legacy/plugins/spaces/index.ts
+++ b/x-pack/legacy/plugins/spaces/index.ts
@@ -34,19 +34,6 @@ export const spaces = (kibana: Record) =>
publicDir: resolve(__dirname, 'public'),
require: ['kibana', 'elasticsearch', 'xpack_main'],
- uiCapabilities() {
- return {
- spaces: {
- manage: true,
- },
- management: {
- kibana: {
- spaces: true,
- },
- },
- };
- },
-
uiExports: {
styleSheetPaths: resolve(__dirname, 'public/index.scss'),
managementSections: [],
@@ -110,14 +97,9 @@ export const spaces = (kibana: Record) =>
throw new Error('New Platform XPack Spaces plugin is not available.');
}
- const config = server.config();
-
const { registerLegacyAPI, createDefaultSpace } = spacesPlugin.__legacyCompat;
registerLegacyAPI({
- legacyConfig: {
- kibanaIndex: config.get('kibana.index'),
- },
savedObjects: server.savedObjects,
auditLogger: {
create: (pluginId: string) =>
diff --git a/x-pack/plugins/features/server/index.ts b/x-pack/plugins/features/server/index.ts
index 2b4f85aa04f04..48ef97a494f7e 100644
--- a/x-pack/plugins/features/server/index.ts
+++ b/x-pack/plugins/features/server/index.ts
@@ -14,7 +14,7 @@ import { Plugin } from './plugin';
export { uiCapabilitiesRegex } from './feature_schema';
export { Feature, FeatureWithAllOrReadPrivileges, FeatureKibanaPrivileges } from '../common';
-export { PluginSetupContract } from './plugin';
+export { PluginSetupContract, PluginStartContract } from './plugin';
export const plugin = (initializerContext: PluginInitializerContext) =>
new Plugin(initializerContext);
diff --git a/x-pack/plugins/features/server/mocks.ts b/x-pack/plugins/features/server/mocks.ts
new file mode 100644
index 0000000000000..ebaa5f1a504ca
--- /dev/null
+++ b/x-pack/plugins/features/server/mocks.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { PluginSetupContract, PluginStartContract } from './plugin';
+
+const createSetup = (): jest.Mocked => {
+ return {
+ getFeatures: jest.fn(),
+ getFeaturesUICapabilities: jest.fn(),
+ registerFeature: jest.fn(),
+ registerLegacyAPI: jest.fn(),
+ };
+};
+
+const createStart = (): jest.Mocked => {
+ return {
+ getFeatures: jest.fn(),
+ };
+};
+
+export const featuresPluginMock = {
+ createSetup,
+ createStart,
+};
diff --git a/x-pack/plugins/features/server/plugin.ts b/x-pack/plugins/features/server/plugin.ts
index 96a8e68f8326d..e77fa218c0681 100644
--- a/x-pack/plugins/features/server/plugin.ts
+++ b/x-pack/plugins/features/server/plugin.ts
@@ -30,6 +30,10 @@ export interface PluginSetupContract {
registerLegacyAPI: (legacyAPI: LegacyAPI) => void;
}
+export interface PluginStartContract {
+ getFeatures(): Feature[];
+}
+
/**
* Describes a set of APIs that are available in the legacy platform only and required by this plugin
* to function properly.
@@ -45,6 +49,8 @@ export interface LegacyAPI {
export class Plugin {
private readonly logger: Logger;
+ private readonly featureRegistry: FeatureRegistry = new FeatureRegistry();
+
private legacyAPI?: LegacyAPI;
private readonly getLegacyAPI = () => {
if (!this.legacyAPI) {
@@ -61,18 +67,16 @@ export class Plugin {
core: CoreSetup,
{ timelion }: { timelion?: TimelionSetupContract }
): Promise> {
- const featureRegistry = new FeatureRegistry();
-
defineRoutes({
router: core.http.createRouter(),
- featureRegistry,
+ featureRegistry: this.featureRegistry,
getLegacyAPI: this.getLegacyAPI,
});
return deepFreeze({
- registerFeature: featureRegistry.register.bind(featureRegistry),
- getFeatures: featureRegistry.getAll.bind(featureRegistry),
- getFeaturesUICapabilities: () => uiCapabilitiesForFeatures(featureRegistry.getAll()),
+ registerFeature: this.featureRegistry.register.bind(this.featureRegistry),
+ getFeatures: this.featureRegistry.getAll.bind(this.featureRegistry),
+ getFeaturesUICapabilities: () => uiCapabilitiesForFeatures(this.featureRegistry.getAll()),
registerLegacyAPI: (legacyAPI: LegacyAPI) => {
this.legacyAPI = legacyAPI;
@@ -82,14 +86,17 @@ export class Plugin {
savedObjectTypes: this.legacyAPI.savedObjectTypes,
includeTimelion: timelion !== undefined && timelion.uiEnabled,
})) {
- featureRegistry.register(feature);
+ this.featureRegistry.register(feature);
}
},
});
}
- public start() {
+ public start(): RecursiveReadonly {
this.logger.debug('Starting plugin');
+ return deepFreeze({
+ getFeatures: this.featureRegistry.getAll.bind(this.featureRegistry),
+ });
}
public stop() {
diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_provider.test.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_provider.test.ts
new file mode 100644
index 0000000000000..8678bdceb70f9
--- /dev/null
+++ b/x-pack/plugins/spaces/server/capabilities/capabilities_provider.test.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 { capabilitiesProvider } from './capabilities_provider';
+
+describe('Capabilities provider', () => {
+ it('provides the expected capabilities', () => {
+ expect(capabilitiesProvider()).toMatchInlineSnapshot(`
+ Object {
+ "management": Object {
+ "kibana": Object {
+ "spaces": true,
+ },
+ },
+ "spaces": Object {
+ "manage": true,
+ },
+ }
+ `);
+ });
+});
diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_provider.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_provider.ts
new file mode 100644
index 0000000000000..5976aabfa66e8
--- /dev/null
+++ b/x-pack/plugins/spaces/server/capabilities/capabilities_provider.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export const capabilitiesProvider = () => ({
+ spaces: {
+ manage: true,
+ },
+ management: {
+ kibana: {
+ spaces: true,
+ },
+ },
+});
diff --git a/x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.test.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts
similarity index 53%
rename from x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.test.ts
rename to x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts
index b92922def2eb8..3f7b93c754aef 100644
--- a/x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.test.ts
+++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts
@@ -6,8 +6,12 @@
import { Feature } from '../../../../plugins/features/server';
import { Space } from '../../common/model/space';
-import { toggleUICapabilities } from './toggle_ui_capabilities';
-import { Capabilities } from 'src/core/public';
+import { setupCapabilitiesSwitcher } from './capabilities_switcher';
+import { Capabilities, CoreSetup } from 'src/core/server';
+import { coreMock, httpServerMock, loggingServiceMock } from 'src/core/server/mocks';
+import { featuresPluginMock } from '../../../features/server/mocks';
+import { spacesServiceMock } from '../spaces_service/spaces_service.mock';
+import { PluginsStart } from '../plugin';
const features: Feature[] = [
{
@@ -91,8 +95,33 @@ const buildCapabilities = () =>
},
}) as Capabilities;
-describe('toggleUiCapabilities', () => {
- it('does not toggle capabilities when the space has no disabled features', () => {
+const setup = (space: Space) => {
+ const coreSetup = coreMock.createSetup();
+
+ const featuresStart = featuresPluginMock.createStart();
+ featuresStart.getFeatures.mockReturnValue(features);
+
+ coreSetup.getStartServices.mockResolvedValue([
+ coreMock.createStart(),
+ { features: featuresStart },
+ ]);
+
+ const spacesService = spacesServiceMock.createSetupContract();
+ spacesService.getActiveSpace.mockResolvedValue(space);
+
+ const logger = loggingServiceMock.createLogger();
+
+ const switcher = setupCapabilitiesSwitcher(
+ (coreSetup as unknown) as CoreSetup,
+ spacesService,
+ logger
+ );
+
+ return { switcher, logger, spacesService };
+};
+
+describe('capabilitiesSwitcher', () => {
+ it('does not toggle capabilities when the space has no disabled features', async () => {
const space: Space = {
id: 'space',
name: '',
@@ -100,11 +129,54 @@ describe('toggleUiCapabilities', () => {
};
const capabilities = buildCapabilities();
- const result = toggleUICapabilities(features, capabilities, space);
+
+ const { switcher } = setup(space);
+ const request = httpServerMock.createKibanaRequest();
+ const result = await switcher(request, capabilities);
+
+ expect(result).toEqual(buildCapabilities());
+ });
+
+ it('does not toggle capabilities when the request is not authenticated', async () => {
+ const space: Space = {
+ id: 'space',
+ name: '',
+ disabledFeatures: ['feature_1', 'feature_2', 'feature_3'],
+ };
+
+ const capabilities = buildCapabilities();
+
+ const { switcher } = setup(space);
+ const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false });
+
+ const result = await switcher(request, capabilities);
+
+ expect(result).toEqual(buildCapabilities());
+ });
+
+ it('logs a warning, and does not toggle capabilities if an error is encountered', async () => {
+ const space: Space = {
+ id: 'space',
+ name: '',
+ disabledFeatures: ['feature_1', 'feature_2', 'feature_3'],
+ };
+
+ const capabilities = buildCapabilities();
+
+ const { switcher, logger, spacesService } = setup(space);
+ const request = httpServerMock.createKibanaRequest();
+
+ spacesService.getActiveSpace.mockRejectedValue(new Error('Something terrible happened'));
+
+ const result = await switcher(request, capabilities);
+
expect(result).toEqual(buildCapabilities());
+ expect(logger.warn).toHaveBeenCalledWith(
+ `Error toggling capabilities for request to /path: Error: Something terrible happened`
+ );
});
- it('ignores unknown disabledFeatures', () => {
+ it('ignores unknown disabledFeatures', async () => {
const space: Space = {
id: 'space',
name: '',
@@ -112,11 +184,15 @@ describe('toggleUiCapabilities', () => {
};
const capabilities = buildCapabilities();
- const result = toggleUICapabilities(features, capabilities, space);
+
+ const { switcher } = setup(space);
+ const request = httpServerMock.createKibanaRequest();
+ const result = await switcher(request, capabilities);
+
expect(result).toEqual(buildCapabilities());
});
- it('disables the corresponding navLink, catalogue, management sections, and all capability flags for disabled features', () => {
+ it('disables the corresponding navLink, catalogue, management sections, and all capability flags for disabled features', async () => {
const space: Space = {
id: 'space',
name: '',
@@ -124,7 +200,10 @@ describe('toggleUiCapabilities', () => {
};
const capabilities = buildCapabilities();
- const result = toggleUICapabilities(features, capabilities, space);
+
+ const { switcher } = setup(space);
+ const request = httpServerMock.createKibanaRequest();
+ const result = await switcher(request, capabilities);
const expectedCapabilities = buildCapabilities();
@@ -137,7 +216,7 @@ describe('toggleUiCapabilities', () => {
expect(result).toEqual(expectedCapabilities);
});
- it('can disable everything', () => {
+ it('can disable everything', async () => {
const space: Space = {
id: 'space',
name: '',
@@ -145,7 +224,10 @@ describe('toggleUiCapabilities', () => {
};
const capabilities = buildCapabilities();
- const result = toggleUICapabilities(features, capabilities, space);
+
+ const { switcher } = setup(space);
+ const request = httpServerMock.createKibanaRequest();
+ const result = await switcher(request, capabilities);
const expectedCapabilities = buildCapabilities();
diff --git a/x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts
similarity index 66%
rename from x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.ts
rename to x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts
index 2de84ec05017b..317cc7fe0e3c3 100644
--- a/x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.ts
+++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts
@@ -4,15 +4,41 @@
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
-import { UICapabilities } from 'ui/capabilities';
+import { Capabilities, CapabilitiesSwitcher, CoreSetup, Logger } from 'src/core/server';
import { Feature } from '../../../../plugins/features/server';
import { Space } from '../../common/model/space';
+import { SpacesServiceSetup } from '../spaces_service';
+import { PluginsStart } from '../plugin';
-export function toggleUICapabilities(
- features: Feature[],
- capabilities: UICapabilities,
- activeSpace: Space
-) {
+export function setupCapabilitiesSwitcher(
+ core: CoreSetup,
+ spacesService: SpacesServiceSetup,
+ logger: Logger
+): CapabilitiesSwitcher {
+ return async (request, capabilities) => {
+ const isAnonymousRequest = !request.route.options.authRequired;
+
+ if (isAnonymousRequest) {
+ return capabilities;
+ }
+
+ try {
+ const [activeSpace, [, { features }]] = await Promise.all([
+ spacesService.getActiveSpace(request),
+ core.getStartServices(),
+ ]);
+
+ const registeredFeatures = features.getFeatures();
+
+ return toggleCapabilities(registeredFeatures, capabilities, activeSpace);
+ } catch (e) {
+ logger.warn(`Error toggling capabilities for request to ${request.url.pathname}: ${e}`);
+ return capabilities;
+ }
+ };
+}
+
+function toggleCapabilities(features: Feature[], capabilities: Capabilities, activeSpace: Space) {
const clonedCapabilities = _.cloneDeep(capabilities);
toggleDisabledFeatures(features, clonedCapabilities, activeSpace);
@@ -22,7 +48,7 @@ export function toggleUICapabilities(
function toggleDisabledFeatures(
features: Feature[],
- capabilities: UICapabilities,
+ capabilities: Capabilities,
activeSpace: Space
) {
const disabledFeatureKeys = activeSpace.disabledFeatures;
diff --git a/x-pack/plugins/spaces/server/capabilities/index.ts b/x-pack/plugins/spaces/server/capabilities/index.ts
new file mode 100644
index 0000000000000..56a72a2eeaf19
--- /dev/null
+++ b/x-pack/plugins/spaces/server/capabilities/index.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { CoreSetup, Logger } from 'src/core/server';
+import { capabilitiesProvider } from './capabilities_provider';
+import { setupCapabilitiesSwitcher } from './capabilities_switcher';
+import { PluginsStart } from '../plugin';
+import { SpacesServiceSetup } from '../spaces_service';
+
+export const setupCapabilities = (
+ core: CoreSetup,
+ spacesService: SpacesServiceSetup,
+ logger: Logger
+) => {
+ core.capabilities.registerProvider(capabilitiesProvider);
+ core.capabilities.registerSwitcher(setupCapabilitiesSwitcher(core, spacesService, logger));
+};
diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts
index 92be88b91c652..61157a9318781 100644
--- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts
+++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts
@@ -201,12 +201,10 @@ describe('onPostAuthInterceptor', () => {
// interceptor to parse out the space id and rewrite the request's URL. Rather than duplicating that logic,
// we are including the already tested interceptor here in the test chain.
initSpacesOnRequestInterceptor({
- getLegacyAPI: () => legacyAPI,
http: (http as unknown) as CoreSetup['http'],
});
initSpacesOnPostAuthRequestInterceptor({
- getLegacyAPI: () => legacyAPI,
http: (http as unknown) as CoreSetup['http'],
log: loggingMock,
features: featuresPlugin,
diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts
index 4674f3641084a..b07ff11f6efc6 100644
--- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts
+++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts
@@ -7,13 +7,12 @@ import { Logger, CoreSetup } from 'src/core/server';
import { Space } from '../../../common/model/space';
import { wrapError } from '../errors';
import { SpacesServiceSetup } from '../../spaces_service/spaces_service';
-import { LegacyAPI, PluginsSetup } from '../../plugin';
+import { PluginsSetup } from '../../plugin';
import { getSpaceSelectorUrl } from '../get_space_selector_url';
import { DEFAULT_SPACE_ID, ENTER_SPACE_PATH } from '../../../common/constants';
import { addSpaceIdToPath } from '../../../common';
export interface OnPostAuthInterceptorDeps {
- getLegacyAPI(): LegacyAPI;
http: CoreSetup['http'];
features: PluginsSetup['features'];
spacesService: SpacesServiceSetup;
@@ -22,7 +21,6 @@ export interface OnPostAuthInterceptorDeps {
export function initSpacesOnPostAuthRequestInterceptor({
features,
- getLegacyAPI,
spacesService,
log,
http,
diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts
index 5e6cf67ee8c90..448bc39eb606e 100644
--- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts
+++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts
@@ -16,7 +16,6 @@ import {
} from '../../../../../../src/core/server';
import * as kbnTestServer from '../../../../../../src/test_utils/kbn_server';
-import { LegacyAPI } from '../../plugin';
import { elasticsearchServiceMock } from 'src/core/server/mocks';
describe('onRequestInterceptor', () => {
@@ -110,10 +109,6 @@ describe('onRequestInterceptor', () => {
elasticsearch.esNodesCompatibility$ = elasticsearchServiceMock.createInternalSetup().esNodesCompatibility$;
initSpacesOnRequestInterceptor({
- getLegacyAPI: () =>
- ({
- legacyConfig: {},
- } as LegacyAPI),
http: (http as unknown) as CoreSetup['http'],
});
diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts
index 22d704c1b7e13..c59851f8b8061 100644
--- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts
+++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts
@@ -12,14 +12,12 @@ import {
import { format } from 'url';
import { DEFAULT_SPACE_ID } from '../../../common/constants';
import { modifyUrl } from '../utils/url';
-import { LegacyAPI } from '../../plugin';
import { getSpaceIdFromPath } from '../../../common';
export interface OnRequestInterceptorDeps {
- getLegacyAPI(): LegacyAPI;
http: CoreSetup['http'];
}
-export function initSpacesOnRequestInterceptor({ getLegacyAPI, http }: OnRequestInterceptorDeps) {
+export function initSpacesOnRequestInterceptor({ http }: OnRequestInterceptorDeps) {
http.registerOnPreAuth(async function spacesOnPreAuthHandler(
request: KibanaRequest,
response: LifecycleResponseFactory,
diff --git a/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts
index a3396e98c3512..094ca8a11816e 100644
--- a/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts
+++ b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts
@@ -23,7 +23,6 @@ import { securityMock } from '../../../security/server/mocks';
const log = loggingServiceMock.createLogger();
const legacyAPI: LegacyAPI = {
- legacyConfig: {},
savedObjects: {} as SavedObjectsLegacyService,
} as LegacyAPI;
diff --git a/x-pack/plugins/spaces/server/plugin.test.ts b/x-pack/plugins/spaces/server/plugin.test.ts
new file mode 100644
index 0000000000000..4e3f4f52cbeb4
--- /dev/null
+++ b/x-pack/plugins/spaces/server/plugin.test.ts
@@ -0,0 +1,72 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { CoreSetup } from 'src/core/server';
+import { coreMock } from 'src/core/server/mocks';
+import { featuresPluginMock } from '../../features/server/mocks';
+import { licensingMock } from '../../licensing/server/mocks';
+import { Plugin, PluginsSetup } from './plugin';
+import { usageCollectionPluginMock } from '../../../../src/plugins/usage_collection/server/mocks';
+
+describe('Spaces Plugin', () => {
+ describe('#setup', () => {
+ it('can setup with all optional plugins disabled, exposing the expected contract', async () => {
+ const initializerContext = coreMock.createPluginInitializerContext({});
+ const core = coreMock.createSetup() as CoreSetup;
+ const features = featuresPluginMock.createSetup();
+ const licensing = licensingMock.createSetup();
+
+ const plugin = new Plugin(initializerContext);
+ const spacesSetup = await plugin.setup(core, { features, licensing });
+ expect(spacesSetup).toMatchInlineSnapshot(`
+ Object {
+ "__legacyCompat": Object {
+ "createDefaultSpace": [Function],
+ "registerLegacyAPI": [Function],
+ },
+ "spacesService": Object {
+ "getActiveSpace": [Function],
+ "getBasePath": [Function],
+ "getSpaceId": [Function],
+ "isInDefaultSpace": [Function],
+ "namespaceToSpaceId": [Function],
+ "scopedClient": [Function],
+ "spaceIdToNamespace": [Function],
+ },
+ }
+ `);
+ });
+
+ it('registers the capabilities provider and switcher', async () => {
+ const initializerContext = coreMock.createPluginInitializerContext({});
+ const core = coreMock.createSetup() as CoreSetup;
+ const features = featuresPluginMock.createSetup();
+ const licensing = licensingMock.createSetup();
+
+ const plugin = new Plugin(initializerContext);
+
+ await plugin.setup(core, { features, licensing });
+
+ expect(core.capabilities.registerProvider).toHaveBeenCalledTimes(1);
+ expect(core.capabilities.registerSwitcher).toHaveBeenCalledTimes(1);
+ });
+
+ it('registers the usage collector', async () => {
+ const initializerContext = coreMock.createPluginInitializerContext({});
+ const core = coreMock.createSetup() as CoreSetup;
+ const features = featuresPluginMock.createSetup();
+ const licensing = licensingMock.createSetup();
+
+ const usageCollection = usageCollectionPluginMock.createSetupContract();
+
+ const plugin = new Plugin(initializerContext);
+
+ await plugin.setup(core, { features, licensing, usageCollection });
+
+ expect(usageCollection.getCollectorByType('spaces')).toBeDefined();
+ });
+ });
+});
diff --git a/x-pack/plugins/spaces/server/plugin.ts b/x-pack/plugins/spaces/server/plugin.ts
index 90c2da6e69df8..d125e0f54e9c1 100644
--- a/x-pack/plugins/spaces/server/plugin.ts
+++ b/x-pack/plugins/spaces/server/plugin.ts
@@ -13,7 +13,10 @@ import {
Logger,
PluginInitializerContext,
} from '../../../../src/core/server';
-import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server';
+import {
+ PluginSetupContract as FeaturesPluginSetup,
+ PluginStartContract as FeaturesPluginStart,
+} from '../../features/server';
import { SecurityPluginSetup } from '../../security/server';
import { LicensingPluginSetup } from '../../licensing/server';
import { createDefaultSpace } from './lib/create_default_space';
@@ -22,15 +25,15 @@ import { AuditLogger } from '../../../../server/lib/audit_logger';
import { spacesSavedObjectsClientWrapperFactory } from './lib/saved_objects_client/saved_objects_client_wrapper_factory';
import { SpacesAuditLogger } from './lib/audit_logger';
import { createSpacesTutorialContextFactory } from './lib/spaces_tutorial_context_factory';
-import { registerSpacesUsageCollector } from './lib/spaces_usage_collector';
+import { registerSpacesUsageCollector } from './usage_collection';
import { SpacesService } from './spaces_service';
import { SpacesServiceSetup } from './spaces_service';
import { ConfigType } from './config';
-import { toggleUICapabilities } from './lib/toggle_ui_capabilities';
import { initSpacesRequestInterceptors } from './lib/request_interceptors';
import { initExternalSpacesApi } from './routes/api/external';
import { initInternalSpacesApi } from './routes/api/internal';
import { initSpacesViewsRoutes } from './routes/views';
+import { setupCapabilities } from './capabilities';
/**
* Describes a set of APIs that is available in the legacy platform only and required by this plugin
@@ -41,9 +44,6 @@ export interface LegacyAPI {
auditLogger: {
create: (pluginId: string) => AuditLogger;
};
- legacyConfig: {
- kibanaIndex: string;
- };
}
export interface PluginsSetup {
@@ -54,6 +54,10 @@ export interface PluginsSetup {
home?: HomeServerPluginSetup;
}
+export interface PluginsStart {
+ features: FeaturesPluginStart;
+}
+
export interface SpacesPluginSetup {
spacesService: SpacesServiceSetup;
__legacyCompat: {
@@ -70,6 +74,8 @@ export class Plugin {
private readonly config$: Observable;
+ private readonly kibanaIndexConfig$: Observable<{ kibana: { index: string } }>;
+
private readonly log: Logger;
private legacyAPI?: LegacyAPI;
@@ -92,12 +98,16 @@ export class Plugin {
constructor(initializerContext: PluginInitializerContext) {
this.config$ = initializerContext.config.create();
+ this.kibanaIndexConfig$ = initializerContext.config.legacy.globalConfig$;
this.log = initializerContext.logger.get();
}
public async start() {}
- public async setup(core: CoreSetup, plugins: PluginsSetup): Promise {
+ public async setup(
+ core: CoreSetup,
+ plugins: PluginsSetup
+ ): Promise {
const service = new SpacesService(this.log, this.getLegacyAPI);
const spacesService = await service.setup({
@@ -131,20 +141,19 @@ export class Plugin {
initSpacesRequestInterceptors({
http: core.http,
log: this.log,
- getLegacyAPI: this.getLegacyAPI,
spacesService,
features: plugins.features,
});
- core.capabilities.registerSwitcher(async (request, uiCapabilities) => {
- try {
- const activeSpace = await spacesService.getActiveSpace(request);
- const features = plugins.features.getFeatures();
- return toggleUICapabilities(features, uiCapabilities, activeSpace);
- } catch (e) {
- return uiCapabilities;
- }
- });
+ setupCapabilities(core, spacesService, this.log);
+
+ if (plugins.usageCollection) {
+ registerSpacesUsageCollector(plugins.usageCollection, {
+ kibanaIndexConfig$: this.kibanaIndexConfig$,
+ features: plugins.features,
+ licensing: plugins.licensing,
+ });
+ }
if (plugins.security) {
plugins.security.registerSpacesService(spacesService);
@@ -161,12 +170,7 @@ export class Plugin {
__legacyCompat: {
registerLegacyAPI: (legacyAPI: LegacyAPI) => {
this.legacyAPI = legacyAPI;
- this.setupLegacyComponents(
- spacesService,
- plugins.features,
- plugins.licensing,
- plugins.usageCollection
- );
+ this.setupLegacyComponents(spacesService);
},
createDefaultSpace: async () => {
return await createDefaultSpace({
@@ -180,12 +184,7 @@ export class Plugin {
public stop() {}
- private setupLegacyComponents(
- spacesService: SpacesServiceSetup,
- featuresSetup: FeaturesPluginSetup,
- licensingSetup: LicensingPluginSetup,
- usageCollectionSetup?: UsageCollectionSetup
- ) {
+ private setupLegacyComponents(spacesService: SpacesServiceSetup) {
const legacyAPI = this.getLegacyAPI();
const { addScopedSavedObjectsClientWrapperFactory, types } = legacyAPI.savedObjects;
addScopedSavedObjectsClientWrapperFactory(
@@ -193,11 +192,5 @@ export class Plugin {
'spaces',
spacesSavedObjectsClientWrapperFactory(spacesService, types)
);
- // Register a function with server to manage the collection of usage stats
- registerSpacesUsageCollector(usageCollectionSetup, {
- kibanaIndex: legacyAPI.legacyConfig.kibanaIndex,
- features: featuresSetup,
- licensing: licensingSetup,
- });
}
}
diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_legacy_api.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_legacy_api.ts
index 812b02e94f591..7765cc3c52e96 100644
--- a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_legacy_api.ts
+++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_legacy_api.ts
@@ -100,9 +100,6 @@ export const createLegacyAPI = ({
} as unknown) as jest.Mocked;
const legacyAPI: jest.Mocked = {
- legacyConfig: {
- kibanaIndex: '',
- },
auditLogger: {} as any,
savedObjects: savedObjectsService,
};
diff --git a/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts b/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts
index 68d096e046ed4..fc5ff39780524 100644
--- a/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts
+++ b/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts
@@ -28,7 +28,6 @@ const mockLogger = loggingServiceMock.createLogger();
const createService = async (serverBasePath: string = '') => {
const legacyAPI = {
- legacyConfig: {},
savedObjects: ({
getSavedObjectsRepository: jest.fn().mockReturnValue({
get: jest.fn().mockImplementation((type, id) => {
diff --git a/x-pack/plugins/spaces/server/usage_collection/index.ts b/x-pack/plugins/spaces/server/usage_collection/index.ts
new file mode 100644
index 0000000000000..01df2b815f5ff
--- /dev/null
+++ b/x-pack/plugins/spaces/server/usage_collection/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { registerSpacesUsageCollector } from './spaces_usage_collector';
diff --git a/x-pack/plugins/spaces/server/lib/spaces_usage_collector.test.ts b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts
similarity index 85%
rename from x-pack/plugins/spaces/server/lib/spaces_usage_collector.test.ts
rename to x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts
index c0a6a152c8322..57ec688ab70e8 100644
--- a/x-pack/plugins/spaces/server/lib/spaces_usage_collector.test.ts
+++ b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts
@@ -9,6 +9,7 @@ import * as Rx from 'rxjs';
import { PluginsSetup } from '../plugin';
import { Feature } from '../../../features/server';
import { ILicense, LicensingPluginSetup } from '../../../licensing/server';
+import { pluginInitializerContextConfigMock } from 'src/core/server/mocks';
interface SetupOpts {
license?: Partial;
@@ -72,7 +73,7 @@ describe('error handling', () => {
license: { isAvailable: true, type: 'basic' },
});
const { fetch: getSpacesUsage } = getSpacesUsageCollector(usageCollecion as any, {
- kibanaIndex: '.kibana',
+ kibanaIndexConfig$: Rx.of({ kibana: { index: '.kibana' } }),
features,
licensing,
});
@@ -85,7 +86,7 @@ describe('error handling', () => {
license: { isAvailable: true, type: 'basic' },
});
const { fetch: getSpacesUsage } = getSpacesUsageCollector(usageCollecion as any, {
- kibanaIndex: '.kibana',
+ kibanaIndexConfig$: Rx.of({ kibana: { index: '.kibana' } }),
features,
licensing,
});
@@ -105,11 +106,25 @@ describe('with a basic license', () => {
license: { isAvailable: true, type: 'basic' },
});
const { fetch: getSpacesUsage } = getSpacesUsageCollector(usageCollecion as any, {
- kibanaIndex: '.kibana',
+ kibanaIndexConfig$: pluginInitializerContextConfigMock({}).legacy.globalConfig$,
features,
licensing,
});
usageStats = await getSpacesUsage(defaultCallClusterMock);
+
+ expect(defaultCallClusterMock).toHaveBeenCalledWith('search', {
+ body: {
+ aggs: {
+ disabledFeatures: {
+ terms: { field: 'space.disabledFeatures', include: ['feature1', 'feature2'], size: 2 },
+ },
+ },
+ query: { term: { type: { value: 'space' } } },
+ size: 0,
+ track_total_hits: true,
+ },
+ index: '.kibana-tests',
+ });
});
test('sets enabled to true', () => {
@@ -139,7 +154,7 @@ describe('with no license', () => {
beforeAll(async () => {
const { features, licensing, usageCollecion } = setup({ license: { isAvailable: false } });
const { fetch: getSpacesUsage } = getSpacesUsageCollector(usageCollecion as any, {
- kibanaIndex: '.kibana',
+ kibanaIndexConfig$: pluginInitializerContextConfigMock({}).legacy.globalConfig$,
features,
licensing,
});
@@ -170,7 +185,7 @@ describe('with platinum license', () => {
license: { isAvailable: true, type: 'platinum' },
});
const { fetch: getSpacesUsage } = getSpacesUsageCollector(usageCollecion as any, {
- kibanaIndex: '.kibana',
+ kibanaIndexConfig$: pluginInitializerContextConfigMock({}).legacy.globalConfig$,
features,
licensing,
});
diff --git a/x-pack/plugins/spaces/server/lib/spaces_usage_collector.ts b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts
similarity index 90%
rename from x-pack/plugins/spaces/server/lib/spaces_usage_collector.ts
rename to x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts
index af77f2d3a72ba..90187b7853185 100644
--- a/x-pack/plugins/spaces/server/lib/spaces_usage_collector.ts
+++ b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts
@@ -4,11 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { get } from 'lodash';
import { CallAPIOptions } from 'src/core/server';
import { take } from 'rxjs/operators';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
-// @ts-ignore
+import { Observable } from 'rxjs';
import { KIBANA_STATS_TYPE_MONITORING } from '../../../../legacy/plugins/monitoring/common/constants';
import { KIBANA_SPACES_STATS_TYPE } from '../../common/constants';
import { PluginsSetup } from '../plugin';
@@ -85,8 +84,8 @@ async function getSpacesUsage(
const { hits, aggregations } = resp!;
- const count = get(hits, 'total.value', 0);
- const disabledFeatureBuckets = get(aggregations, 'disabledFeatures.buckets', []);
+ const count = hits?.total?.value ?? 0;
+ const disabledFeatureBuckets = aggregations?.disabledFeatures?.buckets ?? [];
const initialCounts = knownFeatureIds.reduce(
(acc, featureId) => ({ ...acc, [featureId]: 0 }),
@@ -125,7 +124,7 @@ export interface UsageStats {
}
interface CollectorDeps {
- kibanaIndex: string;
+ kibanaIndexConfig$: Observable<{ kibana: { index: string } }>;
features: PluginsSetup['features'];
licensing: PluginsSetup['licensing'];
}
@@ -145,12 +144,9 @@ export function getSpacesUsageCollector(
const license = await deps.licensing.license$.pipe(take(1)).toPromise();
const available = license.isAvailable; // some form of spaces is available for all valid licenses
- const usageStats = await getSpacesUsage(
- callCluster,
- deps.kibanaIndex,
- deps.features,
- available
- );
+ const kibanaIndex = (await deps.kibanaIndexConfig$.pipe(take(1)).toPromise()).kibana.index;
+
+ const usageStats = await getSpacesUsage(callCluster, kibanaIndex, deps.features, available);
return {
available,
@@ -178,12 +174,9 @@ export function getSpacesUsageCollector(
}
export function registerSpacesUsageCollector(
- usageCollection: UsageCollectionSetup | undefined,
+ usageCollection: UsageCollectionSetup,
deps: CollectorDeps
) {
- if (!usageCollection) {
- return;
- }
const collector = getSpacesUsageCollector(usageCollection, deps);
usageCollection.registerCollector(collector);
}
From 38067da7acd6c77947eafcfe04d894037c5c3351 Mon Sep 17 00:00:00 2001
From: James Gowdy
Date: Fri, 28 Feb 2020 14:30:04 +0000
Subject: [PATCH 06/31] [ML] Fixing annotations alias checks (#58722)
---
.../ml/server/lib/check_annotations/index.d.ts | 11 -----------
.../check_annotations/{index.js => index.ts} | 17 +++++++++++++----
2 files changed, 13 insertions(+), 15 deletions(-)
delete mode 100644 x-pack/plugins/ml/server/lib/check_annotations/index.d.ts
rename x-pack/plugins/ml/server/lib/check_annotations/{index.js => index.ts} (79%)
diff --git a/x-pack/plugins/ml/server/lib/check_annotations/index.d.ts b/x-pack/plugins/ml/server/lib/check_annotations/index.d.ts
deleted file mode 100644
index dbd08eacd3ca2..0000000000000
--- a/x-pack/plugins/ml/server/lib/check_annotations/index.d.ts
+++ /dev/null
@@ -1,11 +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 { IScopedClusterClient } from 'src/core/server';
-
-export function isAnnotationsFeatureAvailable(
- callAsCurrentUser: IScopedClusterClient['callAsCurrentUser']
-): boolean;
diff --git a/x-pack/plugins/ml/server/lib/check_annotations/index.js b/x-pack/plugins/ml/server/lib/check_annotations/index.ts
similarity index 79%
rename from x-pack/plugins/ml/server/lib/check_annotations/index.js
rename to x-pack/plugins/ml/server/lib/check_annotations/index.ts
index 55a90c0cec322..8d9d56ad665c4 100644
--- a/x-pack/plugins/ml/server/lib/check_annotations/index.js
+++ b/x-pack/plugins/ml/server/lib/check_annotations/index.ts
@@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { APICaller } from 'src/core/server';
import { mlLog } from '../../client/log';
import {
@@ -16,23 +17,31 @@ import {
// - ML_ANNOTATIONS_INDEX_PATTERN index is present
// - ML_ANNOTATIONS_INDEX_ALIAS_READ alias is present
// - ML_ANNOTATIONS_INDEX_ALIAS_WRITE alias is present
-export async function isAnnotationsFeatureAvailable(callAsCurrentUser) {
+export async function isAnnotationsFeatureAvailable(callAsCurrentUser: APICaller) {
try {
const indexParams = { index: ML_ANNOTATIONS_INDEX_PATTERN };
const annotationsIndexExists = await callAsCurrentUser('indices.exists', indexParams);
- if (!annotationsIndexExists) return false;
+ if (!annotationsIndexExists) {
+ return false;
+ }
const annotationsReadAliasExists = await callAsCurrentUser('indices.existsAlias', {
+ index: ML_ANNOTATIONS_INDEX_ALIAS_READ,
name: ML_ANNOTATIONS_INDEX_ALIAS_READ,
});
- if (!annotationsReadAliasExists) return false;
+ if (!annotationsReadAliasExists) {
+ return false;
+ }
const annotationsWriteAliasExists = await callAsCurrentUser('indices.existsAlias', {
+ index: ML_ANNOTATIONS_INDEX_ALIAS_WRITE,
name: ML_ANNOTATIONS_INDEX_ALIAS_WRITE,
});
- if (!annotationsWriteAliasExists) return false;
+ if (!annotationsWriteAliasExists) {
+ return false;
+ }
} catch (err) {
mlLog.info('Disabling ML annotations feature because the index/alias integrity check failed.');
return false;
From f86c75893b4265fece1369539eb29478658feed0 Mon Sep 17 00:00:00 2001
From: Dario Gieselaar
Date: Fri, 28 Feb 2020 16:32:46 +0100
Subject: [PATCH 07/31] [APM] Update tsconfig.json template in optimization
script (#58731)
* [APM] Update tsconfig.json template in optimization script
The location of the cypress tests has changed, and those files are no longer excluded in the APM type check. This change updates the tsconfig.json template to exclude the new location.
* Update link in readme
---
x-pack/legacy/plugins/apm/readme.md | 2 +-
.../legacy/plugins/apm/scripts/optimize-tsconfig/tsconfig.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/x-pack/legacy/plugins/apm/readme.md b/x-pack/legacy/plugins/apm/readme.md
index 0edcdc279815c..addf73064716c 100644
--- a/x-pack/legacy/plugins/apm/readme.md
+++ b/x-pack/legacy/plugins/apm/readme.md
@@ -125,6 +125,6 @@ You can access the development environment at http://localhost:9001.
#### Further resources
-- [Cypress integration tests](cypress/README.md)
+- [Cypress integration tests](./e2e/README.md)
- [VSCode setup instructions](./dev_docs/vscode_setup.md)
- [Github PR commands](./dev_docs/github_commands.md)
diff --git a/x-pack/legacy/plugins/apm/scripts/optimize-tsconfig/tsconfig.json b/x-pack/legacy/plugins/apm/scripts/optimize-tsconfig/tsconfig.json
index c2f87503b4548..5021694ff04ac 100644
--- a/x-pack/legacy/plugins/apm/scripts/optimize-tsconfig/tsconfig.json
+++ b/x-pack/legacy/plugins/apm/scripts/optimize-tsconfig/tsconfig.json
@@ -7,6 +7,6 @@
],
"exclude": [
"**/__fixtures__/**/*",
- "./cypress/**/*"
+ "./e2e/cypress/**/*"
]
}
From 7aaf58c86c6932b996a8b42d1258b782a6390cd2 Mon Sep 17 00:00:00 2001
From: Frank Hassanabad
Date: Fri, 28 Feb 2020 08:38:23 -0700
Subject: [PATCH 08/31] Added Security Intelligence And Analytics for
prepackaged_rules (#58808)
---
.github/CODEOWNERS | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index b924c7a1a2c29..de46bcfa69830 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -195,3 +195,7 @@
/x-pack/test/detection_engine_api_integration @elastic/siem
/x-pack/test/api_integration/apis/siem @elastic/siem
/x-pack/plugins/case @elastic/siem
+
+# Security Intelligence And Analytics
+/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules @elastic/security-intelligence-analytics
+/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules @elastic/security-intelligence-analytics
From 2b9dc43158fcb71a87a7e2922783339947b5d95a Mon Sep 17 00:00:00 2001
From: Pierre Gayvallet
Date: Fri, 28 Feb 2020 16:39:06 +0100
Subject: [PATCH 09/31] add dynamic property to type definition (#58852)
---
...vedobjectstypemappingdefinition.dynamic.md | 13 +++
...erver.savedobjectstypemappingdefinition.md | 3 +-
...objectstypemappingdefinition.properties.md | 2 +
.../server/saved_objects/mappings/types.ts | 3 +
.../build_active_mappings.test.ts.snap | 79 +++++++++++++++++++
.../core/build_active_mappings.test.ts | 19 ++++-
src/core/server/server.api.md | 2 +-
7 files changed, 118 insertions(+), 3 deletions(-)
create mode 100644 docs/development/core/server/kibana-plugin-server.savedobjectstypemappingdefinition.dynamic.md
diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectstypemappingdefinition.dynamic.md b/docs/development/core/server/kibana-plugin-server.savedobjectstypemappingdefinition.dynamic.md
new file mode 100644
index 0000000000000..0efab7bebfbe5
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-server.savedobjectstypemappingdefinition.dynamic.md
@@ -0,0 +1,13 @@
+
+
+[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsTypeMappingDefinition](./kibana-plugin-server.savedobjectstypemappingdefinition.md) > [dynamic](./kibana-plugin-server.savedobjectstypemappingdefinition.dynamic.md)
+
+## SavedObjectsTypeMappingDefinition.dynamic property
+
+The dynamic property of the mapping. either `false` or 'strict'. Defaults to strict
+
+Signature:
+
+```typescript
+dynamic?: false | 'strict';
+```
diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectstypemappingdefinition.md b/docs/development/core/server/kibana-plugin-server.savedobjectstypemappingdefinition.md
index 99983d3a9f02b..8c1a279894ffd 100644
--- a/docs/development/core/server/kibana-plugin-server.savedobjectstypemappingdefinition.md
+++ b/docs/development/core/server/kibana-plugin-server.savedobjectstypemappingdefinition.md
@@ -41,5 +41,6 @@ const typeDefinition: SavedObjectsTypeMappingDefinition = {
| Property | Type | Description |
| --- | --- | --- |
-| [properties](./kibana-plugin-server.savedobjectstypemappingdefinition.properties.md) | SavedObjectsMappingProperties
| |
+| [dynamic](./kibana-plugin-server.savedobjectstypemappingdefinition.dynamic.md) | false | 'strict'
| The dynamic property of the mapping. either false
or 'strict'. Defaults to strict |
+| [properties](./kibana-plugin-server.savedobjectstypemappingdefinition.properties.md) | SavedObjectsMappingProperties
| The underlying properties of the type mapping |
diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectstypemappingdefinition.properties.md b/docs/development/core/server/kibana-plugin-server.savedobjectstypemappingdefinition.properties.md
index 555870c3fdd7d..f6be5214ec6d9 100644
--- a/docs/development/core/server/kibana-plugin-server.savedobjectstypemappingdefinition.properties.md
+++ b/docs/development/core/server/kibana-plugin-server.savedobjectstypemappingdefinition.properties.md
@@ -4,6 +4,8 @@
## SavedObjectsTypeMappingDefinition.properties property
+The underlying properties of the type mapping
+
Signature:
```typescript
diff --git a/src/core/server/saved_objects/mappings/types.ts b/src/core/server/saved_objects/mappings/types.ts
index 578fdcea3718e..bc556c0429981 100644
--- a/src/core/server/saved_objects/mappings/types.ts
+++ b/src/core/server/saved_objects/mappings/types.ts
@@ -45,6 +45,9 @@
* @public
*/
export interface SavedObjectsTypeMappingDefinition {
+ /** The dynamic property of the mapping. either `false` or 'strict'. Defaults to strict */
+ dynamic?: false | 'strict';
+ /** The underlying properties of the type mapping */
properties: SavedObjectsMappingProperties;
}
diff --git a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap
index e82fbfc85dfa0..68f90ea70a0c6 100644
--- a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap
+++ b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap
@@ -60,3 +60,82 @@ Object {
},
}
`;
+
+exports[`buildActiveMappings handles the \`dynamic\` property of types 1`] = `
+Object {
+ "_meta": Object {
+ "migrationMappingPropertyHashes": Object {
+ "config": "87aca8fdb053154f11383fce3dbf3edf",
+ "firstType": "635418ab953d81d93f1190b70a8d3f57",
+ "migrationVersion": "4a1746014a75ade3a714e1db5763276f",
+ "namespace": "2f4316de49999235636386fe51dc06c1",
+ "references": "7997cf5a56cc02bdc9c93361bde732b0",
+ "secondType": "72d57924f415fbadb3ee293b67d233ab",
+ "thirdType": "510f1f0adb69830cf8a1c5ce2923ed82",
+ "type": "2f4316de49999235636386fe51dc06c1",
+ "updated_at": "00da57df13e94e9d98437d13ace4bfe0",
+ },
+ },
+ "dynamic": "strict",
+ "properties": Object {
+ "config": Object {
+ "dynamic": "true",
+ "properties": Object {
+ "buildNum": Object {
+ "type": "keyword",
+ },
+ },
+ },
+ "firstType": Object {
+ "dynamic": "strict",
+ "properties": Object {
+ "field": Object {
+ "type": "keyword",
+ },
+ },
+ },
+ "migrationVersion": Object {
+ "dynamic": "true",
+ "type": "object",
+ },
+ "namespace": Object {
+ "type": "keyword",
+ },
+ "references": Object {
+ "properties": Object {
+ "id": Object {
+ "type": "keyword",
+ },
+ "name": Object {
+ "type": "keyword",
+ },
+ "type": Object {
+ "type": "keyword",
+ },
+ },
+ "type": "nested",
+ },
+ "secondType": Object {
+ "dynamic": false,
+ "properties": Object {
+ "field": Object {
+ "type": "long",
+ },
+ },
+ },
+ "thirdType": Object {
+ "properties": Object {
+ "field": Object {
+ "type": "text",
+ },
+ },
+ },
+ "type": Object {
+ "type": "keyword",
+ },
+ "updated_at": Object {
+ "type": "date",
+ },
+ },
+}
+`;
diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.test.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.test.ts
index 9d220cfdf94b7..33e1a395e64a2 100644
--- a/src/core/server/saved_objects/migrations/core/build_active_mappings.test.ts
+++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.test.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { IndexMapping } from './../../mappings';
+import { IndexMapping, SavedObjectsTypeMappingDefinitions } from './../../mappings';
import { buildActiveMappings, diffMappings } from './build_active_mappings';
describe('buildActiveMappings', () => {
@@ -49,6 +49,23 @@ describe('buildActiveMappings', () => {
);
});
+ test('handles the `dynamic` property of types', () => {
+ const typeMappings: SavedObjectsTypeMappingDefinitions = {
+ firstType: {
+ dynamic: 'strict',
+ properties: { field: { type: 'keyword' } },
+ },
+ secondType: {
+ dynamic: false,
+ properties: { field: { type: 'long' } },
+ },
+ thirdType: {
+ properties: { field: { type: 'text' } },
+ },
+ };
+ expect(buildActiveMappings(typeMappings)).toMatchSnapshot();
+ });
+
test('generated hashes are stable', () => {
const properties = {
aaa: { type: 'keyword', fields: { a: { type: 'keyword' }, b: { type: 'text' } } },
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index 8f4feb7169651..42bc1ce214b19 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -2058,7 +2058,7 @@ export interface SavedObjectsType {
// @public
export interface SavedObjectsTypeMappingDefinition {
- // (undocumented)
+ dynamic?: false | 'strict';
properties: SavedObjectsMappingProperties;
}
From 29fbe395b71bf1a2d8de9e3d10cfa3440e8eb47f Mon Sep 17 00:00:00 2001
From: Chris Cowan
Date: Fri, 28 Feb 2020 09:42:52 -0700
Subject: [PATCH 10/31] [Metrics UI] Use CPU Usage limits for Kubernetes pods
when available (#58424)
Co-authored-by: Elastic Machine
---
.../pod/metrics/snapshot/cpu.ts | 20 ++++++++++++++++++-
.../pod/metrics/tsvb/pod_cpu_usage.ts | 16 ++++++++++++++-
2 files changed, 34 insertions(+), 2 deletions(-)
diff --git a/x-pack/plugins/infra/common/inventory_models/pod/metrics/snapshot/cpu.ts b/x-pack/plugins/infra/common/inventory_models/pod/metrics/snapshot/cpu.ts
index f25dd8179aa1a..d5979d455f0bf 100644
--- a/x-pack/plugins/infra/common/inventory_models/pod/metrics/snapshot/cpu.ts
+++ b/x-pack/plugins/infra/common/inventory_models/pod/metrics/snapshot/cpu.ts
@@ -7,9 +7,27 @@
import { SnapshotModel } from '../../../types';
export const cpu: SnapshotModel = {
- cpu: {
+ cpu_with_limit: {
+ avg: {
+ field: 'kubernetes.pod.cpu.usage.limit.pct',
+ },
+ },
+ cpu_without_limit: {
avg: {
field: 'kubernetes.pod.cpu.usage.node.pct',
},
},
+ cpu: {
+ bucket_script: {
+ buckets_path: {
+ with_limit: 'cpu_with_limit',
+ without_limit: 'cpu_without_limit',
+ },
+ script: {
+ source: 'params.with_limit > 0.0 ? params.with_limit : params.without_limit',
+ lang: 'painless',
+ },
+ gap_policy: 'skip',
+ },
+ },
};
diff --git a/x-pack/plugins/infra/common/inventory_models/pod/metrics/tsvb/pod_cpu_usage.ts b/x-pack/plugins/infra/common/inventory_models/pod/metrics/tsvb/pod_cpu_usage.ts
index 1d778d11e0725..52d48c6329e51 100644
--- a/x-pack/plugins/infra/common/inventory_models/pod/metrics/tsvb/pod_cpu_usage.ts
+++ b/x-pack/plugins/infra/common/inventory_models/pod/metrics/tsvb/pod_cpu_usage.ts
@@ -24,9 +24,23 @@ export const podCpuUsage: TSVBMetricModelCreator = (
metrics: [
{
field: 'kubernetes.pod.cpu.usage.node.pct',
- id: 'avg-cpu-usage',
+ id: 'avg-cpu-without',
type: 'avg',
},
+ {
+ field: 'kubernetes.pod.cpu.usage.limit.pct',
+ id: 'avg-cpu-with',
+ type: 'avg',
+ },
+ {
+ id: 'cpu-usage',
+ type: 'calculation',
+ variables: [
+ { id: 'cpu_with', name: 'with_limit', field: 'avg-cpu-with' },
+ { id: 'cpu_without', name: 'without_limit', field: 'avg-cpu-without' },
+ ],
+ script: 'params.with_limit > 0.0 ? params.with_limit : params.without_limit',
+ },
],
},
],
From 5b7270541ccbcb923e4b7728478276a17225d85e Mon Sep 17 00:00:00 2001
From: Patrick Mueller
Date: Fri, 28 Feb 2020 11:48:28 -0500
Subject: [PATCH 11/31] [alerting] initial index threshold alertType and
supporting APIs (#57030)
Adds the first built-in alertType for Kibana alerting, an index threshold alert, and associated HTTP endpoint to generate preview data for it.
addresses the server-side requirements for issue https://github.com/elastic/kibana/issues/53041
---
x-pack/.i18nrc.json | 1 +
x-pack/plugins/alerting_builtins/README.md | 23 ++
x-pack/plugins/alerting_builtins/kibana.json | 8 +
.../server/alert_types/index.ts | 19 ++
.../alert_types/index_threshold/README.md | 271 +++++++++++++++++
.../index_threshold/action_context.test.ts | 89 ++++++
.../index_threshold/action_context.ts | 69 +++++
.../index_threshold/alert_type.test.ts | 56 ++++
.../alert_types/index_threshold/alert_type.ts | 129 ++++++++
.../index_threshold/alert_type_params.test.ts | 67 +++++
.../index_threshold/alert_type_params.ts | 65 ++++
.../alert_types/index_threshold/index.ts | 37 +++
.../lib/core_query_types.test.ts | 165 ++++++++++
.../index_threshold/lib/core_query_types.ts | 108 +++++++
.../lib/date_range_info.test.ts | 225 ++++++++++++++
.../index_threshold/lib/date_range_info.ts | 128 ++++++++
.../lib/time_series_query.test.ts | 64 ++++
.../index_threshold/lib/time_series_query.ts | 152 ++++++++++
.../lib/time_series_types.test.ts | 105 +++++++
.../index_threshold/lib/time_series_types.ts | 106 +++++++
.../alert_types/index_threshold/routes.ts | 53 ++++
.../alerting_builtins/server/config.ts | 13 +
.../plugins/alerting_builtins/server/index.ts | 17 ++
.../alerting_builtins/server/plugin.test.ts | 70 +++++
.../alerting_builtins/server/plugin.ts | 40 +++
.../plugins/alerting_builtins/server/types.ts | 34 +++
.../common/lib/es_test_index_tool.ts | 10 +
.../alerting/builtin_alert_types/index.ts | 14 +
.../index_threshold/create_test_data.ts | 72 +++++
.../index_threshold/index.ts | 14 +
.../index_threshold/query_data_endpoint.ts | 283 ++++++++++++++++++
.../spaces_only/tests/alerting/index.ts | 1 +
32 files changed, 2508 insertions(+)
create mode 100644 x-pack/plugins/alerting_builtins/README.md
create mode 100644 x-pack/plugins/alerting_builtins/kibana.json
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/README.md
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.test.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/index.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.test.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.test.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/routes.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/config.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/index.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/plugin.test.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/plugin.ts
create mode 100644 x-pack/plugins/alerting_builtins/server/types.ts
create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts
create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts
create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts
create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/query_data_endpoint.ts
diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json
index 66342266f1dbc..51099815ec938 100644
--- a/x-pack/.i18nrc.json
+++ b/x-pack/.i18nrc.json
@@ -4,6 +4,7 @@
"xpack.actions": "plugins/actions",
"xpack.advancedUiActions": "plugins/advanced_ui_actions",
"xpack.alerting": "plugins/alerting",
+ "xpack.alertingBuiltins": "plugins/alerting_builtins",
"xpack.apm": ["legacy/plugins/apm", "plugins/apm"],
"xpack.beatsManagement": "legacy/plugins/beats_management",
"xpack.canvas": "legacy/plugins/canvas",
diff --git a/x-pack/plugins/alerting_builtins/README.md b/x-pack/plugins/alerting_builtins/README.md
new file mode 100644
index 0000000000000..233984a1ff23f
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/README.md
@@ -0,0 +1,23 @@
+# alerting_builtins plugin
+
+This plugin provides alertTypes shipped with Kibana for use with the
+[the alerting plugin](../alerting/README.md). When enabled, it will register
+the built-in alertTypes with the alerting plugin, register associated HTTP
+routes, etc.
+
+The plugin `setup` and `start` contracts for this plugin are the following
+type, which provides some runtime capabilities. Each built-in alertType will
+have it's own top-level property in the `IService` interface, if it needs to
+expose functionality.
+
+```ts
+export interface IService {
+ indexThreshold: {
+ timeSeriesQuery(params: TimeSeriesQueryParameters): Promise;
+ }
+}
+```
+
+Each built-in alertType is described in it's own README:
+
+- index threshold: [`server/alert_types/index_threshold`](server/alert_types/index_threshold/README.md)
diff --git a/x-pack/plugins/alerting_builtins/kibana.json b/x-pack/plugins/alerting_builtins/kibana.json
new file mode 100644
index 0000000000000..cd6bb7519c093
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/kibana.json
@@ -0,0 +1,8 @@
+{
+ "id": "alertingBuiltins",
+ "server": true,
+ "version": "8.0.0",
+ "kibanaVersion": "kibana",
+ "requiredPlugins": ["alerting"],
+ "ui": false
+}
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index.ts
new file mode 100644
index 0000000000000..475efc87b443a
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Service, IRouter, AlertingSetup } from '../types';
+import { register as registerIndexThreshold } from './index_threshold';
+
+interface RegisterBuiltInAlertTypesParams {
+ service: Service;
+ router: IRouter;
+ alerting: AlertingSetup;
+ baseRoute: string;
+}
+
+export function registerBuiltInAlertTypes(params: RegisterBuiltInAlertTypesParams) {
+ registerIndexThreshold(params);
+}
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/README.md b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/README.md
new file mode 100644
index 0000000000000..b1a9e6daaaee3
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/README.md
@@ -0,0 +1,271 @@
+# built-in alertType index threshold
+
+directory in plugin: `server/alert_types/index_threshold`
+
+The index threshold alert type is designed to run an ES query over indices,
+aggregating field values from documents, comparing them to threshold values,
+and scheduling actions to run when the thresholds are met.
+
+And example would be checking a monitoring index for percent cpu usage field
+values that are greater than some threshold, which could then be used to invoke
+an action (email, slack, etc) to notify interested parties when the threshold
+is exceeded.
+
+## alertType `.index-threshold`
+
+The alertType parameters are specified in
+[`lib/core_query_types.ts`][it-core-query]
+and
+[`alert_type_params.ts`][it-alert-params].
+
+The alertType has a single actionGroup, `'threshold met'`. The `context` object
+provided to actions is specified in
+[`action_context.ts`][it-alert-context].
+
+[it-alert-params]: alert_type_params.ts
+[it-alert-context]: action_context.ts
+[it-core-query]: lib/core_query_types.ts
+
+### example
+
+This example uses [kbn-action][]'s `kbn-alert` command to create the alert,
+and [es-hb-sim][] to generate ES documents for the alert to run queries
+against.
+
+Start `es-hb-sim`:
+
+```
+es-hb-sim 1 es-hb-sim host-A https://elastic:changeme@localhost:9200
+```
+
+This will start indexing documents of the following form, to the `es-hb-sim`
+index:
+
+```
+{"@timestamp":"2020-02-20T22:10:30.011Z","summary":{"up":1,"down":0},"monitor":{"status":"up","name":"host-A"}}
+```
+
+Press `u` to have it start writing "down" documents instead of "up" documents.
+
+Create a server log action that we can use with the alert:
+
+```
+export ACTION_ID=`kbn-action create .server-log 'server-log' '{}' '{}' | jq -r '.id'`
+```
+
+Finally, create the alert:
+
+```
+kbn-alert create .index-threshold 'es-hb-sim threshold' 1s \
+ '{
+ index: es-hb-sim
+ timeField: @timestamp
+ aggType: average
+ aggField: summary.up
+ groupField: monitor.name.keyword
+ window: 5s
+ comparator: lessThan
+ threshold: [ 0.6 ]
+ }' \
+ "[
+ {
+ group: threshold met
+ id: '$ACTION_ID'
+ params: {
+ level: warn
+ message: '{{context.message}}'
+ }
+ }
+ ]"
+```
+
+This alert will run a query over the `es-hb-sim` index, using the `@timestamp`
+field as the date field, using an `average` aggregation over the `summary.up`
+field. The results are then aggregated by `monitor.name.keyword`. If we ran
+another instance of `es-hb-sim`, using `host-B` instead of `host-A`, then the
+alert will end up potentially scheduling actions for both, independently.
+Within the alerting plugin, this grouping is also referred to as "instanceIds"
+(`host-A` and `host-B` being distinct instanceIds, which can have actions
+scheduled against them independently).
+
+The `window` is set to `5s` which is 5 seconds. That means, every time the
+alert runs it's queries (every second, in the example above), it will run it's
+ES query over the last 5 seconds. Thus, the queries, over time, will overlap.
+Sometimes that's what you want. Other times, maybe you just want to do
+sampling, running an alert every hour, with a 5 minute window. Up to the you!
+
+Using the `comparator` `lessThan` and `threshold` `[0.6]`, the alert will
+calculate the average of all the `summary.up` fields for each unique
+`monitor.name.keyword`, and then if the value is less than 0.6, it will
+schedule the specified action (server log) to run. The `message` param
+passed to the action includes a mustache template for the context variable
+`message`, which is created by the alert type. That message generates
+a generic but useful text message, already constructed. Alternatively,
+a customer could set the `message` param in the action to a much more
+complex message, using other context variables made available by the
+alert type.
+
+Here's the message you should see in the Kibana console, if everything is
+working:
+
+```
+server log [17:32:10.060] [warning][actions][actions][plugins] \
+ Server log: alert es-hb-sim threshold instance host-A value 0 \
+ exceeded threshold average(summary.up) lessThan 0.6 over 5s \
+ on 2020-02-20T22:32:07.000Z
+```
+
+[kbn-action]: https://github.com/pmuellr/kbn-action
+[es-hb-sim]: https://github.com/pmuellr/es-hb-sim
+[now-iso]: https://github.com/pmuellr/now-iso
+
+
+## http endpoints
+
+An HTTP endpoint is provided to return the values the alertType would calculate,
+over a series of time. This is intended to be used in the alerting UI to
+provide a "preview" of the alert during creation/editing based on recent data,
+and could be used to show a "simulation" of the the alert over an arbitrary
+range of time.
+
+The endpoint is `POST /api/alerting_builtins/index_threshold/_time_series_query`.
+The request and response bodies are specifed in
+[`lib/core_query_types.ts`][it-core-query]
+and
+[`lib/time_series_types.ts`][it-timeSeries-types].
+The request body is very similar to the alertType's parameters.
+
+### example
+
+Continuing with the example above, here's a query to get the values calculated
+for the last 10 seconds.
+This example uses [now-iso][] to generate iso date strings.
+
+```console
+curl -k "https://elastic:changeme@localhost:5601/api/alerting_builtins/index_threshold/_time_series_query" \
+ -H "kbn-xsrf: foo" -H "content-type: application/json" -d "{
+ \"index\": \"es-hb-sim\",
+ \"timeField\": \"@timestamp\",
+ \"aggType\": \"average\",
+ \"aggField\": \"summary.up\",
+ \"groupField\": \"monitor.name.keyword\",
+ \"interval\": \"1s\",
+ \"dateStart\": \"`now-iso -10s`\",
+ \"dateEnd\": \"`now-iso`\",
+ \"window\": \"5s\"
+}"
+```
+
+```
+{
+ "results": [
+ {
+ "group": "host-A",
+ "metrics": [
+ [ "2020-02-26T15:10:40.000Z", 0 ],
+ [ "2020-02-26T15:10:41.000Z", 0 ],
+ [ "2020-02-26T15:10:42.000Z", 0 ],
+ [ "2020-02-26T15:10:43.000Z", 0 ],
+ [ "2020-02-26T15:10:44.000Z", 0 ],
+ [ "2020-02-26T15:10:45.000Z", 0 ],
+ [ "2020-02-26T15:10:46.000Z", 0 ],
+ [ "2020-02-26T15:10:47.000Z", 0 ],
+ [ "2020-02-26T15:10:48.000Z", 0 ],
+ [ "2020-02-26T15:10:49.000Z", 0 ],
+ [ "2020-02-26T15:10:50.000Z", 0 ]
+ ]
+ }
+ ]
+}
+```
+
+To get the current value of the calculated metric, you can leave off the date:
+
+```
+curl -k "https://elastic:changeme@localhost:5601/api/alerting_builtins/index_threshold/_time_series_query" \
+ -H "kbn-xsrf: foo" -H "content-type: application/json" -d '{
+ "index": "es-hb-sim",
+ "timeField": "@timestamp",
+ "aggType": "average",
+ "aggField": "summary.up",
+ "groupField": "monitor.name.keyword",
+ "interval": "1s",
+ "window": "5s"
+}'
+```
+
+```
+{
+ "results": [
+ {
+ "group": "host-A",
+ "metrics": [
+ [ "2020-02-26T15:23:36.635Z", 0 ]
+ ]
+ }
+ ]
+}
+```
+
+[it-timeSeries-types]: lib/time_series_types.ts
+
+## service functions
+
+A single service function is available that provides the functionality
+of the http endpoint `POST /api/alerting_builtins/index_threshold/_time_series_query`,
+but as an API for Kibana plugins. The function is available as
+`alertingService.indexThreshold.timeSeriesQuery()`
+
+The parameters and return value for the function are the same as for the HTTP
+request, though some additional parameters are required (logger, callCluster,
+etc).
+
+## notes on the timeSeriesQuery API / http endpoint
+
+This API provides additional parameters beyond what the alertType itself uses:
+
+- `dateStart`
+- `dateEnd`
+- `interval`
+
+The `dateStart` and `dateEnd` parameters are ISO date strings.
+
+The `interval` parameter is intended to model the `interval` the alert is
+currently using, and uses the same `1s`, `2m`, `3h`, etc format. Over the
+supplied date range, a time-series data point will be calculated every
+`interval` duration.
+
+So the number of time-series points in the output of the API should be:
+
+```
+( dateStart - dateEnd ) / interval
+```
+
+Example:
+
+```
+dateStart: '2020-01-01T00:00:00'
+dateEnd: '2020-01-02T00:00:00'
+interval: '1h'
+```
+
+The date range is 1 day === 24 hours. The interval is 1 hour. So there should
+be ~24 time series points in the output.
+
+For preview purposes:
+
+- The `groupLimit` parameter should be used to help cut
+down on the amount of work ES does, and keep the generated graphs a little
+simpler. Probably something like `10`.
+
+- For queries with long date ranges, you probably don't want to use the
+`interval` the alert is set to, as the `interval` used in the query, as this
+could result in a lot of time-series points being generated, which is both
+costly in ES, and may result in noisy graphs.
+
+- The `window` parameter should be the same as what the alert is using,
+especially for the `count` and `sum` aggregation types. Those aggregations
+don't scale the same way the others do, when the window changes. Even for
+the other aggregations, changing the window could result in dramatically
+different values being generated - `averages` will be more "average-y", `min`
+and `max` will be a little stickier.
\ No newline at end of file
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts
new file mode 100644
index 0000000000000..fbadf14f1d560
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts
@@ -0,0 +1,89 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { BaseActionContext, addMessages } from './action_context';
+import { ParamsSchema } from './alert_type_params';
+
+describe('ActionContext', () => {
+ it('generates expected properties if aggField is null', async () => {
+ const base: BaseActionContext = {
+ date: '2020-01-01T00:00:00.000Z',
+ group: '[group]',
+ name: '[name]',
+ spaceId: '[spaceId]',
+ namespace: '[spaceId]',
+ value: 42,
+ };
+ const params = ParamsSchema.validate({
+ index: '[index]',
+ timeField: '[timeField]',
+ aggType: 'count',
+ window: '5m',
+ comparator: 'greaterThan',
+ threshold: [4],
+ });
+ const context = addMessages(base, params);
+ expect(context.subject).toMatchInlineSnapshot(
+ `"alert [name] group [group] exceeded threshold"`
+ );
+ expect(context.message).toMatchInlineSnapshot(
+ `"alert [name] group [group] value 42 exceeded threshold count greaterThan 4 over 5m on 2020-01-01T00:00:00.000Z"`
+ );
+ });
+
+ it('generates expected properties if aggField is not null', async () => {
+ const base: BaseActionContext = {
+ date: '2020-01-01T00:00:00.000Z',
+ group: '[group]',
+ name: '[name]',
+ spaceId: '[spaceId]',
+ namespace: '[spaceId]',
+ value: 42,
+ };
+ const params = ParamsSchema.validate({
+ index: '[index]',
+ timeField: '[timeField]',
+ aggType: 'average',
+ aggField: '[aggField]',
+ window: '5m',
+ comparator: 'greaterThan',
+ threshold: [4.2],
+ });
+ const context = addMessages(base, params);
+ expect(context.subject).toMatchInlineSnapshot(
+ `"alert [name] group [group] exceeded threshold"`
+ );
+ expect(context.message).toMatchInlineSnapshot(
+ `"alert [name] group [group] value 42 exceeded threshold average([aggField]) greaterThan 4.2 over 5m on 2020-01-01T00:00:00.000Z"`
+ );
+ });
+
+ it('generates expected properties if comparator is between', async () => {
+ const base: BaseActionContext = {
+ date: '2020-01-01T00:00:00.000Z',
+ group: '[group]',
+ name: '[name]',
+ spaceId: '[spaceId]',
+ namespace: '[spaceId]',
+ value: 4,
+ };
+ const params = ParamsSchema.validate({
+ index: '[index]',
+ timeField: '[timeField]',
+ aggType: 'count',
+ window: '5m',
+ comparator: 'between',
+ threshold: [4, 5],
+ });
+ const context = addMessages(base, params);
+ expect(context.subject).toMatchInlineSnapshot(
+ `"alert [name] group [group] exceeded threshold"`
+ );
+ expect(context.message).toMatchInlineSnapshot(
+ `"alert [name] group [group] value 4 exceeded threshold count between 4,5 over 5m on 2020-01-01T00:00:00.000Z"`
+ );
+ });
+});
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.ts
new file mode 100644
index 0000000000000..98a8e5ae14b7f
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.ts
@@ -0,0 +1,69 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { Params } from './alert_type_params';
+
+// alert type context provided to actions
+
+export interface ActionContext extends BaseActionContext {
+ // a short generic message which may be used in an action message
+ subject: string;
+ // a longer generic message which may be used in an action message
+ message: string;
+}
+
+export interface BaseActionContext {
+ // the alert name
+ name: string;
+ // the spaceId of the alert
+ spaceId: string;
+ // the namespace of the alert (spaceId === (namespace || 'default')
+ namespace?: string;
+ // the alert tags
+ tags?: string[];
+ // the aggType used in the alert
+ // the value of the aggField, if used, otherwise 'all documents'
+ group: string;
+ // the date the alert was run as an ISO date
+ date: string;
+ // the value that met the threshold
+ value: number;
+}
+
+export function addMessages(c: BaseActionContext, p: Params): ActionContext {
+ const subject = i18n.translate(
+ 'xpack.alertingBuiltins.indexThreshold.alertTypeContextSubjectTitle',
+ {
+ defaultMessage: 'alert {name} group {group} exceeded threshold',
+ values: {
+ name: c.name,
+ group: c.group,
+ },
+ }
+ );
+
+ const agg = p.aggField ? `${p.aggType}(${p.aggField})` : `${p.aggType}`;
+ const humanFn = `${agg} ${p.comparator} ${p.threshold.join(',')}`;
+
+ const message = i18n.translate(
+ 'xpack.alertingBuiltins.indexThreshold.alertTypeContextMessageDescription',
+ {
+ defaultMessage:
+ 'alert {name} group {group} value {value} exceeded threshold {function} over {window} on {date}',
+ values: {
+ name: c.name,
+ group: c.group,
+ value: c.value,
+ function: humanFn,
+ window: p.window,
+ date: c.date,
+ },
+ }
+ );
+
+ return { ...c, subject, message };
+}
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts
new file mode 100644
index 0000000000000..f6e26cdaa283a
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.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 { loggingServiceMock } from '../../../../../../src/core/server/mocks';
+import { getAlertType } from './alert_type';
+
+describe('alertType', () => {
+ const service = {
+ indexThreshold: {
+ timeSeriesQuery: jest.fn(),
+ },
+ logger: loggingServiceMock.create().get(),
+ };
+
+ const alertType = getAlertType(service);
+
+ it('alert type creation structure is the expected value', async () => {
+ expect(alertType.id).toBe('.index-threshold');
+ expect(alertType.name).toBe('Index Threshold');
+ expect(alertType.actionGroups).toEqual([{ id: 'threshold met', name: 'Threshold Met' }]);
+ });
+
+ it('validator succeeds with valid params', async () => {
+ const params = {
+ index: 'index-name',
+ timeField: 'time-field',
+ aggType: 'count',
+ window: '5m',
+ comparator: 'greaterThan',
+ threshold: [0],
+ };
+
+ expect(alertType.validate?.params?.validate(params)).toBeTruthy();
+ });
+
+ it('validator fails with invalid params', async () => {
+ const paramsSchema = alertType.validate?.params;
+ if (!paramsSchema) throw new Error('params validator not set');
+
+ const params = {
+ index: 'index-name',
+ timeField: 'time-field',
+ aggType: 'foo',
+ window: '5m',
+ comparator: 'greaterThan',
+ threshold: [0],
+ };
+
+ expect(() => paramsSchema.validate(params)).toThrowErrorMatchingInlineSnapshot(
+ `"[aggType]: invalid aggType: \\"foo\\""`
+ );
+ });
+});
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts
new file mode 100644
index 0000000000000..2b0c07ed4355a
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts
@@ -0,0 +1,129 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { AlertType, AlertExecutorOptions } from '../../types';
+import { Params, ParamsSchema } from './alert_type_params';
+import { BaseActionContext, addMessages } from './action_context';
+
+export const ID = '.index-threshold';
+
+import { Service } from '../../types';
+
+const ActionGroupId = 'threshold met';
+const ComparatorFns = getComparatorFns();
+export const ComparatorFnNames = new Set(ComparatorFns.keys());
+
+export function getAlertType(service: Service): AlertType {
+ const { logger } = service;
+
+ const alertTypeName = i18n.translate('xpack.alertingBuiltins.indexThreshold.alertTypeTitle', {
+ defaultMessage: 'Index Threshold',
+ });
+
+ const actionGroupName = i18n.translate(
+ 'xpack.alertingBuiltins.indexThreshold.actionGroupThresholdMetTitle',
+ {
+ defaultMessage: 'Threshold Met',
+ }
+ );
+
+ return {
+ id: ID,
+ name: alertTypeName,
+ actionGroups: [{ id: ActionGroupId, name: actionGroupName }],
+ defaultActionGroupId: ActionGroupId,
+ validate: {
+ params: ParamsSchema,
+ },
+ executor,
+ };
+
+ async function executor(options: AlertExecutorOptions) {
+ const { alertId, name, services } = options;
+ const params: Params = options.params as Params;
+
+ const compareFn = ComparatorFns.get(params.comparator);
+ if (compareFn == null) {
+ throw new Error(getInvalidComparatorMessage(params.comparator));
+ }
+
+ const callCluster = services.callCluster;
+ const date = new Date().toISOString();
+ // the undefined values below are for config-schema optional types
+ const queryParams = {
+ index: params.index,
+ timeField: params.timeField,
+ aggType: params.aggType,
+ aggField: params.aggField,
+ groupField: params.groupField,
+ groupLimit: params.groupLimit,
+ dateStart: date,
+ dateEnd: date,
+ window: params.window,
+ interval: undefined,
+ };
+ const result = await service.indexThreshold.timeSeriesQuery({
+ logger,
+ callCluster,
+ query: queryParams,
+ });
+ logger.debug(`alert ${ID}:${alertId} "${name}" query result: ${JSON.stringify(result)}`);
+
+ const groupResults = result.results || [];
+ for (const groupResult of groupResults) {
+ const instanceId = groupResult.group;
+ const value = groupResult.metrics[0][1];
+ const met = compareFn(value, params.threshold);
+
+ if (!met) continue;
+
+ const baseContext: BaseActionContext = {
+ name,
+ spaceId: options.spaceId,
+ namespace: options.namespace,
+ tags: options.tags,
+ date,
+ group: instanceId,
+ value,
+ };
+ const actionContext = addMessages(baseContext, params);
+ const alertInstance = options.services.alertInstanceFactory(instanceId);
+ alertInstance.scheduleActions(ActionGroupId, actionContext);
+ logger.debug(`scheduled actionGroup: ${JSON.stringify(actionContext)}`);
+ }
+ }
+}
+
+export function getInvalidComparatorMessage(comparator: string) {
+ return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidComparatorErrorMessage', {
+ defaultMessage: 'invalid comparator specified: {comparator}',
+ values: {
+ comparator,
+ },
+ });
+}
+
+type ComparatorFn = (value: number, threshold: number[]) => boolean;
+
+function getComparatorFns(): Map {
+ const fns: Record = {
+ lessThan: (value: number, threshold: number[]) => value < threshold[0],
+ lessThanOrEqual: (value: number, threshold: number[]) => value <= threshold[0],
+ greaterThanOrEqual: (value: number, threshold: number[]) => value >= threshold[0],
+ greaterThan: (value: number, threshold: number[]) => value > threshold[0],
+ between: (value: number, threshold: number[]) => value >= threshold[0] && value <= threshold[1],
+ notBetween: (value: number, threshold: number[]) =>
+ value < threshold[0] || value > threshold[1],
+ };
+
+ const result = new Map();
+ for (const key of Object.keys(fns)) {
+ result.set(key, fns[key]);
+ }
+
+ return result;
+}
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.test.ts
new file mode 100644
index 0000000000000..b9f66cfa7a253
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.test.ts
@@ -0,0 +1,67 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ParamsSchema } from './alert_type_params';
+import { runTests } from './lib/core_query_types.test';
+
+const DefaultParams = {
+ index: 'index-name',
+ timeField: 'time-field',
+ aggType: 'count',
+ window: '5m',
+ comparator: 'greaterThan',
+ threshold: [0],
+};
+
+describe('alertType Params validate()', () => {
+ runTests(ParamsSchema, DefaultParams);
+
+ let params: any;
+ beforeEach(() => {
+ params = { ...DefaultParams };
+ });
+
+ it('passes for minimal valid input', async () => {
+ expect(validate()).toBeTruthy();
+ });
+
+ it('passes for maximal valid input', async () => {
+ params.aggType = 'average';
+ params.aggField = 'agg-field';
+ params.groupField = 'group-field';
+ params.groupLimit = 100;
+ expect(validate()).toBeTruthy();
+ });
+
+ it('fails for invalid comparator', async () => {
+ params.comparator = '[invalid-comparator]';
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[comparator]: invalid comparator specified: [invalid-comparator]"`
+ );
+ });
+
+ it('fails for invalid threshold length', async () => {
+ params.comparator = 'lessThan';
+ params.threshold = [0, 1];
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[threshold]: must have one element for the \\"lessThan\\" comparator"`
+ );
+
+ params.comparator = 'between';
+ params.threshold = [0];
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[threshold]: must have two elements for the \\"between\\" comparator"`
+ );
+ });
+
+ function onValidate(): () => void {
+ return () => validate();
+ }
+
+ function validate(): any {
+ return ParamsSchema.validate(params);
+ }
+});
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.ts
new file mode 100644
index 0000000000000..d5b83f9f6ad5a
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.ts
@@ -0,0 +1,65 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { schema, TypeOf } from '@kbn/config-schema';
+import { ComparatorFnNames, getInvalidComparatorMessage } from './alert_type';
+import { CoreQueryParamsSchemaProperties, validateCoreQueryBody } from './lib/core_query_types';
+
+// alert type parameters
+
+export type Params = TypeOf;
+
+export const ParamsSchema = schema.object(
+ {
+ ...CoreQueryParamsSchemaProperties,
+ // the comparison function to use to determine if the threshold as been met
+ comparator: schema.string({ validate: validateComparator }),
+ // the values to use as the threshold; `between` and `notBetween` require
+ // two values, the others require one.
+ threshold: schema.arrayOf(schema.number(), { minSize: 1, maxSize: 2 }),
+ },
+ {
+ validate: validateParams,
+ }
+);
+
+const betweenComparators = new Set(['between', 'notBetween']);
+
+// using direct type not allowed, circular reference, so body is typed to any
+function validateParams(anyParams: any): string | undefined {
+ // validate core query parts, return if it fails validation (returning string)
+ const coreQueryValidated = validateCoreQueryBody(anyParams);
+ if (coreQueryValidated) return coreQueryValidated;
+
+ const { comparator, threshold }: Params = anyParams;
+
+ if (betweenComparators.has(comparator)) {
+ if (threshold.length === 1) {
+ return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidThreshold2ErrorMessage', {
+ defaultMessage: '[threshold]: must have two elements for the "{comparator}" comparator',
+ values: {
+ comparator,
+ },
+ });
+ }
+ } else {
+ if (threshold.length === 2) {
+ return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidThreshold1ErrorMessage', {
+ defaultMessage: '[threshold]: must have one element for the "{comparator}" comparator',
+ values: {
+ comparator,
+ },
+ });
+ }
+ }
+}
+
+export function validateComparator(comparator: string): string | undefined {
+ if (ComparatorFnNames.has(comparator)) return;
+
+ return getInvalidComparatorMessage(comparator);
+}
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/index.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/index.ts
new file mode 100644
index 0000000000000..05c6101e0a515
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/index.ts
@@ -0,0 +1,37 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Service, AlertingSetup, IRouter } from '../../types';
+import { timeSeriesQuery } from './lib/time_series_query';
+import { getAlertType } from './alert_type';
+import { createTimeSeriesQueryRoute } from './routes';
+
+// future enhancement: make these configurable?
+export const MAX_INTERVALS = 1000;
+export const MAX_GROUPS = 1000;
+export const DEFAULT_GROUPS = 100;
+
+export function getService() {
+ return {
+ timeSeriesQuery,
+ };
+}
+
+interface RegisterParams {
+ service: Service;
+ router: IRouter;
+ alerting: AlertingSetup;
+ baseRoute: string;
+}
+
+export function register(params: RegisterParams) {
+ const { service, router, alerting, baseRoute } = params;
+
+ alerting.registerType(getAlertType(service));
+
+ const alertTypeBaseRoute = `${baseRoute}/index_threshold`;
+ createTimeSeriesQueryRoute(service, router, alertTypeBaseRoute);
+}
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts
new file mode 100644
index 0000000000000..b4f061adb8f54
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts
@@ -0,0 +1,165 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+// tests of common properties on time_series_query and alert_type_params
+
+import { ObjectType } from '@kbn/config-schema';
+
+import { MAX_GROUPS } from '../index';
+
+const DefaultParams: Record = {
+ index: 'index-name',
+ timeField: 'time-field',
+ aggType: 'count',
+ window: '5m',
+};
+
+export function runTests(schema: ObjectType, defaultTypeParams: Record): void {
+ let params: any;
+
+ describe('coreQueryTypes', () => {
+ beforeEach(() => {
+ params = { ...DefaultParams, ...defaultTypeParams };
+ });
+
+ it('succeeds with minimal properties', async () => {
+ expect(validate()).toBeTruthy();
+ });
+
+ it('succeeds with maximal properties', async () => {
+ params.aggType = 'average';
+ params.aggField = 'agg-field';
+ params.groupField = 'group-field';
+ params.groupLimit = 200;
+ expect(validate()).toBeTruthy();
+ });
+
+ it('fails for invalid index', async () => {
+ delete params.index;
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[index]: expected value of type [string] but got [undefined]"`
+ );
+
+ params.index = 42;
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[index]: expected value of type [string] but got [number]"`
+ );
+
+ params.index = '';
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[index]: value is [] but it must have a minimum length of [1]."`
+ );
+ });
+
+ it('fails for invalid timeField', async () => {
+ delete params.timeField;
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[timeField]: expected value of type [string] but got [undefined]"`
+ );
+
+ params.timeField = 42;
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[timeField]: expected value of type [string] but got [number]"`
+ );
+
+ params.timeField = '';
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[timeField]: value is [] but it must have a minimum length of [1]."`
+ );
+ });
+
+ it('fails for invalid aggType', async () => {
+ params.aggType = 42;
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[aggType]: expected value of type [string] but got [number]"`
+ );
+
+ params.aggType = '-not-a-valid-aggType-';
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[aggType]: invalid aggType: \\"-not-a-valid-aggType-\\""`
+ );
+ });
+
+ it('fails for invalid aggField', async () => {
+ params.aggField = 42;
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[aggField]: expected value of type [string] but got [number]"`
+ );
+
+ params.aggField = '';
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[aggField]: value is [] but it must have a minimum length of [1]."`
+ );
+ });
+
+ it('fails for invalid groupField', async () => {
+ params.groupField = 42;
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[groupField]: expected value of type [string] but got [number]"`
+ );
+
+ params.groupField = '';
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[groupField]: value is [] but it must have a minimum length of [1]."`
+ );
+ });
+
+ it('fails for invalid groupLimit', async () => {
+ params.groupLimit = 'foo';
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[groupLimit]: expected value of type [number] but got [string]"`
+ );
+
+ params.groupLimit = 0;
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[groupLimit]: must be greater than 0"`
+ );
+
+ params.groupLimit = MAX_GROUPS + 1;
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[groupLimit]: must be less than or equal to 1000"`
+ );
+ });
+
+ it('fails for invalid window', async () => {
+ params.window = 42;
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[window]: expected value of type [string] but got [number]"`
+ );
+
+ params.window = 'x';
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[window]: invalid duration: \\"x\\""`
+ );
+ });
+
+ it('fails for invalid aggType/aggField', async () => {
+ params.aggType = 'count';
+ params.aggField = 'agg-field-1';
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[aggField]: must not have a value when [aggType] is \\"count\\""`
+ );
+
+ params.aggType = 'average';
+ delete params.aggField;
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[aggField]: must have a value when [aggType] is \\"average\\""`
+ );
+ });
+ });
+
+ function onValidate(): () => void {
+ return () => validate();
+ }
+
+ function validate(): any {
+ return schema.validate(params);
+ }
+}
+
+describe('coreQueryTypes wrapper', () => {
+ test('this test suite is meant to be called via the export', () => {});
+});
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.ts
new file mode 100644
index 0000000000000..265a70eba4d6b
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.ts
@@ -0,0 +1,108 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+// common properties on time_series_query and alert_type_params
+
+import { i18n } from '@kbn/i18n';
+import { schema, TypeOf } from '@kbn/config-schema';
+
+import { MAX_GROUPS } from '../index';
+import { parseDuration } from '../../../../../alerting/server';
+
+export const CoreQueryParamsSchemaProperties = {
+ // name of the index to search
+ index: schema.string({ minLength: 1 }),
+ // field in index used for date/time
+ timeField: schema.string({ minLength: 1 }),
+ // aggregation type
+ aggType: schema.string({ validate: validateAggType }),
+ // aggregation field
+ aggField: schema.maybe(schema.string({ minLength: 1 })),
+ // group field
+ groupField: schema.maybe(schema.string({ minLength: 1 })),
+ // limit on number of groups returned
+ groupLimit: schema.maybe(schema.number()),
+ // size of time window for date range aggregations
+ window: schema.string({ validate: validateDuration }),
+};
+
+const CoreQueryParamsSchema = schema.object(CoreQueryParamsSchemaProperties);
+export type CoreQueryParams = TypeOf;
+
+// Meant to be used in a "subclass"'s schema body validator, so the
+// anyParams object is assumed to have been validated with the schema
+// above.
+// Using direct type not allowed, circular reference, so body is typed to any.
+export function validateCoreQueryBody(anyParams: any): string | undefined {
+ const { aggType, aggField, groupLimit }: CoreQueryParams = anyParams;
+
+ if (aggType === 'count' && aggField) {
+ return i18n.translate('xpack.alertingBuiltins.indexThreshold.aggTypeNotEmptyErrorMessage', {
+ defaultMessage: '[aggField]: must not have a value when [aggType] is "{aggType}"',
+ values: {
+ aggType,
+ },
+ });
+ }
+
+ if (aggType !== 'count' && !aggField) {
+ return i18n.translate('xpack.alertingBuiltins.indexThreshold.aggTypeRequiredErrorMessage', {
+ defaultMessage: '[aggField]: must have a value when [aggType] is "{aggType}"',
+ values: {
+ aggType,
+ },
+ });
+ }
+
+ // schema.number doesn't seem to check the max value ...
+ if (groupLimit != null) {
+ if (groupLimit <= 0) {
+ return i18n.translate(
+ 'xpack.alertingBuiltins.indexThreshold.invalidGroupMinimumErrorMessage',
+ {
+ defaultMessage: '[groupLimit]: must be greater than 0',
+ }
+ );
+ }
+ if (groupLimit > MAX_GROUPS) {
+ return i18n.translate(
+ 'xpack.alertingBuiltins.indexThreshold.invalidGroupMaximumErrorMessage',
+ {
+ defaultMessage: '[groupLimit]: must be less than or equal to {maxGroups}',
+ values: {
+ maxGroups: MAX_GROUPS,
+ },
+ }
+ );
+ }
+ }
+}
+
+const AggTypes = new Set(['count', 'average', 'min', 'max', 'sum']);
+
+function validateAggType(aggType: string): string | undefined {
+ if (AggTypes.has(aggType)) return;
+
+ return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidAggTypeErrorMessage', {
+ defaultMessage: 'invalid aggType: "{aggType}"',
+ values: {
+ aggType,
+ },
+ });
+}
+
+export function validateDuration(duration: string): string | undefined {
+ try {
+ parseDuration(duration);
+ } catch (err) {
+ return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidDurationErrorMessage', {
+ defaultMessage: 'invalid duration: "{duration}"',
+ values: {
+ duration,
+ },
+ });
+ }
+}
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.test.ts
new file mode 100644
index 0000000000000..eff5ef2567784
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.test.ts
@@ -0,0 +1,225 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { times } from 'lodash';
+
+import { getDateRangeInfo, DateRangeInfo } from './date_range_info';
+
+// dates to test with, separated by 1m, starting with BaseDate, descending
+const BaseDate = Date.parse('2000-01-01T00:00:00Z');
+const Dates: string[] = [];
+
+// array of date strings, starting at 2000-01-01T00:00:00Z, decreasing by 1 minute
+times(10, index => Dates.push(new Date(BaseDate - index * 1000 * 60).toISOString()));
+
+const DEFAULT_WINDOW_MINUTES = 5;
+
+const BaseRangeQuery = {
+ window: `${DEFAULT_WINDOW_MINUTES}m`,
+};
+
+describe('getRangeInfo', () => {
+ it('should return 1 date range when no dateStart or interval specified', async () => {
+ const info = getDateRangeInfo({ ...BaseRangeQuery, dateEnd: Dates[0] });
+ const rInfo = asReadableDateRangeInfo(info);
+ expect(rInfo).toMatchInlineSnapshot(`
+ Object {
+ "dateStart": "1999-12-31T23:55:00.000Z",
+ "dateStop_": "2000-01-01T00:00:00.000Z",
+ "ranges": Array [
+ Object {
+ "f": "1999-12-31T23:55:00.000Z",
+ "t": "2000-01-01T00:00:00.000Z",
+ },
+ ],
+ }
+ `);
+ });
+
+ it('should return 1 date range when no dateStart specified', async () => {
+ const info = getDateRangeInfo({ ...BaseRangeQuery, dateEnd: Dates[0], interval: '1000d' });
+ const rInfo = asReadableDateRangeInfo(info);
+ expect(rInfo).toMatchInlineSnapshot(`
+ Object {
+ "dateStart": "1999-12-31T23:55:00.000Z",
+ "dateStop_": "2000-01-01T00:00:00.000Z",
+ "ranges": Array [
+ Object {
+ "f": "1999-12-31T23:55:00.000Z",
+ "t": "2000-01-01T00:00:00.000Z",
+ },
+ ],
+ }
+ `);
+ });
+
+ it('should return 1 date range when no interval specified', async () => {
+ const info = getDateRangeInfo({ ...BaseRangeQuery, dateStart: Dates[1], dateEnd: Dates[0] });
+ const rInfo = asReadableDateRangeInfo(info);
+ expect(rInfo).toMatchInlineSnapshot(`
+ Object {
+ "dateStart": "1999-12-31T23:55:00.000Z",
+ "dateStop_": "2000-01-01T00:00:00.000Z",
+ "ranges": Array [
+ Object {
+ "f": "1999-12-31T23:55:00.000Z",
+ "t": "2000-01-01T00:00:00.000Z",
+ },
+ ],
+ }
+ `);
+ });
+
+ it('should return 2 date ranges as expected', async () => {
+ const info = getDateRangeInfo({
+ ...BaseRangeQuery,
+ dateStart: Dates[1],
+ dateEnd: Dates[0],
+ interval: '1m',
+ });
+ const rInfo = asReadableDateRangeInfo(info);
+ expect(rInfo).toMatchInlineSnapshot(`
+ Object {
+ "dateStart": "1999-12-31T23:54:00.000Z",
+ "dateStop_": "2000-01-01T00:00:00.000Z",
+ "ranges": Array [
+ Object {
+ "f": "1999-12-31T23:54:00.000Z",
+ "t": "1999-12-31T23:59:00.000Z",
+ },
+ Object {
+ "f": "1999-12-31T23:55:00.000Z",
+ "t": "2000-01-01T00:00:00.000Z",
+ },
+ ],
+ }
+ `);
+ });
+
+ it('should return 3 date ranges as expected', async () => {
+ const info = getDateRangeInfo({
+ ...BaseRangeQuery,
+ dateStart: Dates[2],
+ dateEnd: Dates[0],
+ interval: '1m',
+ });
+ const rInfo = asReadableDateRangeInfo(info);
+ expect(rInfo).toMatchInlineSnapshot(`
+ Object {
+ "dateStart": "1999-12-31T23:53:00.000Z",
+ "dateStop_": "2000-01-01T00:00:00.000Z",
+ "ranges": Array [
+ Object {
+ "f": "1999-12-31T23:53:00.000Z",
+ "t": "1999-12-31T23:58:00.000Z",
+ },
+ Object {
+ "f": "1999-12-31T23:54:00.000Z",
+ "t": "1999-12-31T23:59:00.000Z",
+ },
+ Object {
+ "f": "1999-12-31T23:55:00.000Z",
+ "t": "2000-01-01T00:00:00.000Z",
+ },
+ ],
+ }
+ `);
+ });
+
+ it('should handle no dateStart, dateEnd or interval specified', async () => {
+ const nowM0 = Date.now();
+ const nowM5 = nowM0 - 1000 * 60 * 5;
+
+ const info = getDateRangeInfo(BaseRangeQuery);
+ expect(sloppyMilliDiff(nowM5, Date.parse(info.dateStart))).toBeCloseTo(0);
+ expect(sloppyMilliDiff(nowM0, Date.parse(info.dateEnd))).toBeCloseTo(0);
+ expect(info.dateRanges.length).toEqual(1);
+ expect(info.dateRanges[0].from).toEqual(info.dateStart);
+ expect(info.dateRanges[0].to).toEqual(info.dateEnd);
+ });
+
+ it('should throw an error if passed dateStart > dateEnd', async () => {
+ const params = {
+ ...BaseRangeQuery,
+ dateStart: '2020-01-01T00:00:00.000Z',
+ dateEnd: '2000-01-01T00:00:00.000Z',
+ };
+ expect(() => getDateRangeInfo(params)).toThrowErrorMatchingInlineSnapshot(
+ `"[dateStart]: is greater than [dateEnd]"`
+ );
+ });
+
+ it('should throw an error if passed an unparseable dateStart', async () => {
+ const params = {
+ ...BaseRangeQuery,
+ dateStart: 'woopsie',
+ };
+ expect(() => getDateRangeInfo(params)).toThrowErrorMatchingInlineSnapshot(
+ `"invalid date format for dateStart: \\"woopsie\\""`
+ );
+ });
+
+ it('should throw an error if passed an unparseable dateEnd', async () => {
+ const params = {
+ ...BaseRangeQuery,
+ dateStart: '2020-01-01T00:00:00.000Z',
+ dateEnd: 'woopsie',
+ };
+ expect(() => getDateRangeInfo(params)).toThrowErrorMatchingInlineSnapshot(
+ `"invalid date format for dateEnd: \\"woopsie\\""`
+ );
+ });
+
+ it('should throw an error if passed an unparseable window', async () => {
+ const params = { window: 'woopsie' };
+ expect(() => getDateRangeInfo(params)).toThrowErrorMatchingInlineSnapshot(
+ `"invalid duration format for window: \\"woopsie\\""`
+ );
+ });
+
+ it('should throw an error if passed an unparseable interval', async () => {
+ const params = {
+ ...BaseRangeQuery,
+ interval: 'woopsie',
+ };
+ expect(() => getDateRangeInfo(params)).toThrowErrorMatchingInlineSnapshot(
+ `"invalid duration format for interval: \\"woopsie\\""`
+ );
+ });
+
+ it('should throw an error if too many intervals calculated', async () => {
+ const params = {
+ ...BaseRangeQuery,
+ dateStart: '2000-01-01T00:00:00.000Z',
+ dateEnd: '2020-01-01T00:00:00.000Z',
+ interval: '1s',
+ };
+ expect(() => getDateRangeInfo(params)).toThrowErrorMatchingInlineSnapshot(
+ `"calculated number of intervals 631152001 is greater than maximum 1000"`
+ );
+ });
+});
+
+// Calculate 1/1000 of the millisecond diff between two millisecond values,
+// to be used with jest `toBeCloseTo()`
+function sloppyMilliDiff(ms1: number | string, ms2: number | string) {
+ const m1 = typeof ms1 === 'number' ? ms1 : Date.parse(ms1);
+ const m2 = typeof ms2 === 'number' ? ms2 : Date.parse(ms2);
+ return Math.abs(m1 - m2) / 1000;
+}
+
+function asReadableDateRangeInfo(info: DateRangeInfo) {
+ return {
+ dateStart: info.dateStart,
+ dateStop_: info.dateEnd,
+ ranges: info.dateRanges.map(dateRange => {
+ return {
+ f: new Date(dateRange.from).toISOString(),
+ t: new Date(dateRange.to).toISOString(),
+ };
+ }),
+ };
+}
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.ts
new file mode 100644
index 0000000000000..0a4accc983d79
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.ts
@@ -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 { i18n } from '@kbn/i18n';
+import { times } from 'lodash';
+import { parseDuration } from '../../../../../alerting/server';
+import { MAX_INTERVALS } from '../index';
+
+// dates as numbers are epoch millis
+// dates as strings are ISO
+
+export interface DateRange {
+ from: string;
+ to: string;
+}
+
+export interface DateRangeInfo {
+ dateStart: string;
+ dateEnd: string;
+ dateRanges: DateRange[];
+}
+
+export interface GetDateRangeInfoParams {
+ dateStart?: string;
+ dateEnd?: string;
+ interval?: string;
+ window: string;
+}
+
+// Given a start and end date, an interval, and a window, calculate the
+// array of date ranges, each date range is offset by it's peer by one interval,
+// and each date range is window milliseconds long.
+export function getDateRangeInfo(params: GetDateRangeInfoParams): DateRangeInfo {
+ const { dateStart: dateStartS, dateEnd: dateEndS, interval: intervalS, window: windowS } = params;
+
+ // get dates in epoch millis, interval and window in millis
+ const dateEnd = getDateOrUndefined(dateEndS, 'dateEnd') || Date.now();
+ const dateStart = getDateOrUndefined(dateStartS, 'dateStart') || dateEnd;
+
+ if (dateStart > dateEnd) throw new Error(getDateStartAfterDateEndErrorMessage());
+
+ const interval = getDurationOrUndefined(intervalS, 'interval') || 0;
+ const window = getDuration(windowS, 'window');
+
+ // Start from the end, as it's more likely the user wants precision there.
+ // We'll reverse the resultant ranges at the end, to get ascending order.
+ let dateCurrent = dateEnd;
+ const dateRanges: DateRange[] = [];
+
+ // Calculate number of intervals; if no interval specified, only calculate one.
+ const intervals = !interval ? 1 : 1 + Math.round((dateEnd - dateStart) / interval);
+ if (intervals > MAX_INTERVALS) {
+ throw new Error(getTooManyIntervalsErrorMessage(intervals, MAX_INTERVALS));
+ }
+
+ times(intervals, () => {
+ dateRanges.push({
+ from: new Date(dateCurrent - window).toISOString(),
+ to: new Date(dateCurrent).toISOString(),
+ });
+ dateCurrent -= interval;
+ });
+
+ // reverse in-place
+ dateRanges.reverse();
+
+ return {
+ dateStart: dateRanges[0].from,
+ dateEnd: dateRanges[dateRanges.length - 1].to,
+ dateRanges,
+ };
+}
+
+function getDateOrUndefined(dateS: string | undefined, field: string): number | undefined {
+ if (!dateS) return undefined;
+ return getDate(dateS, field);
+}
+
+function getDate(dateS: string, field: string): number {
+ const date = Date.parse(dateS);
+ if (isNaN(date)) throw new Error(getParseErrorMessage('date', field, dateS));
+
+ return date.valueOf();
+}
+
+function getDurationOrUndefined(durationS: string | undefined, field: string): number | undefined {
+ if (!durationS) return undefined;
+ return getDuration(durationS, field);
+}
+
+function getDuration(durationS: string, field: string): number {
+ try {
+ return parseDuration(durationS);
+ } catch (err) {
+ throw new Error(getParseErrorMessage('duration', field, durationS));
+ }
+}
+
+function getParseErrorMessage(formatName: string, fieldName: string, fieldValue: string) {
+ return i18n.translate('xpack.alertingBuiltins.indexThreshold.formattedFieldErrorMessage', {
+ defaultMessage: 'invalid {formatName} format for {fieldName}: "{fieldValue}"',
+ values: {
+ formatName,
+ fieldName,
+ fieldValue,
+ },
+ });
+}
+
+export function getTooManyIntervalsErrorMessage(intervals: number, maxIntervals: number) {
+ return i18n.translate('xpack.alertingBuiltins.indexThreshold.maxIntervalsErrorMessage', {
+ defaultMessage:
+ 'calculated number of intervals {intervals} is greater than maximum {maxIntervals}',
+ values: {
+ intervals,
+ maxIntervals,
+ },
+ });
+}
+
+export function getDateStartAfterDateEndErrorMessage(): string {
+ return i18n.translate('xpack.alertingBuiltins.indexThreshold.dateStartGTdateEndErrorMessage', {
+ defaultMessage: '[dateStart]: is greater than [dateEnd]',
+ });
+}
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts
new file mode 100644
index 0000000000000..1955cdfa4cea6
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts
@@ -0,0 +1,64 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+// test error conditions of calling timeSeriesQuery - postive results tested in FT
+
+import { loggingServiceMock } from '../../../../../../../src/core/server/mocks';
+import { coreMock } from '../../../../../../../src/core/server/mocks';
+import { AlertingBuiltinsPlugin } from '../../../plugin';
+import { TimeSeriesQueryParameters, TimeSeriesResult } from './time_series_query';
+
+type TimeSeriesQuery = (params: TimeSeriesQueryParameters) => Promise;
+
+const DefaultQueryParams = {
+ index: 'index-name',
+ timeField: 'time-field',
+ aggType: 'count',
+ aggField: undefined,
+ window: '5m',
+ dateStart: undefined,
+ dateEnd: undefined,
+ interval: undefined,
+ groupField: undefined,
+ groupLimit: undefined,
+};
+
+describe('timeSeriesQuery', () => {
+ let params: TimeSeriesQueryParameters;
+ const mockCallCluster = jest.fn();
+
+ let timeSeriesQuery: TimeSeriesQuery;
+
+ beforeEach(async () => {
+ // rather than use the function from an import, retrieve it from the plugin
+ const context = coreMock.createPluginInitializerContext();
+ const plugin = new AlertingBuiltinsPlugin(context);
+ const coreStart = coreMock.createStart();
+ const service = await plugin.start(coreStart);
+ timeSeriesQuery = service.indexThreshold.timeSeriesQuery;
+
+ mockCallCluster.mockReset();
+ params = {
+ logger: loggingServiceMock.create().get(),
+ callCluster: mockCallCluster,
+ query: { ...DefaultQueryParams },
+ };
+ });
+
+ it('fails as expected when the callCluster call fails', async () => {
+ mockCallCluster.mockRejectedValue(new Error('woopsie'));
+ expect(timeSeriesQuery(params)).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"error running search"`
+ );
+ });
+
+ it('fails as expected when the query params are invalid', async () => {
+ params.query = { ...params.query, dateStart: 'x' };
+ expect(timeSeriesQuery(params)).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"invalid date format for dateStart: \\"x\\""`
+ );
+ });
+});
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.ts
new file mode 100644
index 0000000000000..8ea2a7dd1dcc5
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.ts
@@ -0,0 +1,152 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { DEFAULT_GROUPS } from '../index';
+import { getDateRangeInfo } from './date_range_info';
+import { Logger, CallCluster } from '../../../types';
+
+import { TimeSeriesQuery, TimeSeriesResult, TimeSeriesResultRow } from './time_series_types';
+export { TimeSeriesQuery, TimeSeriesResult } from './time_series_types';
+
+export interface TimeSeriesQueryParameters {
+ logger: Logger;
+ callCluster: CallCluster;
+ query: TimeSeriesQuery;
+}
+
+export async function timeSeriesQuery(
+ params: TimeSeriesQueryParameters
+): Promise {
+ const { logger, callCluster, query: queryParams } = params;
+ const { index, window, interval, timeField, dateStart, dateEnd } = queryParams;
+
+ const dateRangeInfo = getDateRangeInfo({ dateStart, dateEnd, window, interval });
+
+ // core query
+ const esQuery: any = {
+ index,
+ body: {
+ size: 0,
+ query: {
+ bool: {
+ filter: {
+ range: {
+ [timeField]: {
+ gte: dateRangeInfo.dateStart,
+ lt: dateRangeInfo.dateEnd,
+ format: 'strict_date_time',
+ },
+ },
+ },
+ },
+ },
+ // aggs: {...}, filled in below
+ },
+ ignoreUnavailable: true,
+ allowNoIndices: true,
+ ignore: [404],
+ };
+
+ // add the aggregations
+ const { aggType, aggField, groupField, groupLimit } = queryParams;
+
+ const isCountAgg = aggType === 'count';
+ const isGroupAgg = !!groupField;
+
+ let aggParent = esQuery.body;
+
+ // first, add a group aggregation, if requested
+ if (isGroupAgg) {
+ aggParent.aggs = {
+ groupAgg: {
+ terms: {
+ field: groupField,
+ size: groupLimit || DEFAULT_GROUPS,
+ },
+ },
+ };
+ aggParent = aggParent.aggs.groupAgg;
+ }
+
+ // next, add the time window aggregation
+ aggParent.aggs = {
+ dateAgg: {
+ date_range: {
+ field: timeField,
+ ranges: dateRangeInfo.dateRanges,
+ },
+ },
+ };
+ aggParent = aggParent.aggs.dateAgg;
+
+ // finally, the metric aggregation, if requested
+ const actualAggType = aggType === 'average' ? 'avg' : aggType;
+ if (!isCountAgg) {
+ aggParent.aggs = {
+ metricAgg: {
+ [actualAggType]: {
+ field: aggField,
+ },
+ },
+ };
+ }
+
+ let esResult: any;
+ const logPrefix = 'indexThreshold timeSeriesQuery: callCluster';
+ logger.debug(`${logPrefix} call: ${JSON.stringify(esQuery)}`);
+
+ try {
+ esResult = await callCluster('search', esQuery);
+ } catch (err) {
+ logger.warn(`${logPrefix} error: ${JSON.stringify(err.message)}`);
+ throw new Error('error running search');
+ }
+
+ logger.debug(`${logPrefix} result: ${JSON.stringify(esResult)}`);
+ return getResultFromEs(isCountAgg, isGroupAgg, esResult);
+}
+
+function getResultFromEs(
+ isCountAgg: boolean,
+ isGroupAgg: boolean,
+ esResult: Record
+): TimeSeriesResult {
+ const aggregations = esResult?.aggregations || {};
+
+ // add a fake 'all documents' group aggregation, if a group aggregation wasn't used
+ if (!isGroupAgg) {
+ const dateAgg = aggregations.dateAgg || {};
+
+ aggregations.groupAgg = {
+ buckets: [{ key: 'all documents', dateAgg }],
+ };
+
+ delete aggregations.dateAgg;
+ }
+
+ const groupBuckets = aggregations.groupAgg?.buckets || [];
+ const result: TimeSeriesResult = {
+ results: [],
+ };
+
+ for (const groupBucket of groupBuckets) {
+ const groupName: string = `${groupBucket?.key}`;
+ const dateBuckets = groupBucket?.dateAgg?.buckets || [];
+ const groupResult: TimeSeriesResultRow = {
+ group: groupName,
+ metrics: [],
+ };
+ result.results.push(groupResult);
+
+ for (const dateBucket of dateBuckets) {
+ const date: string = dateBucket.to_as_string;
+ const value: number = isCountAgg ? dateBucket.doc_count : dateBucket.metricAgg.value;
+ groupResult.metrics.push([date, value]);
+ }
+ }
+
+ return result;
+}
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.test.ts
new file mode 100644
index 0000000000000..d69d48efcdf6b
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.test.ts
@@ -0,0 +1,105 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { TimeSeriesQuerySchema } from './time_series_types';
+import { runTests } from './core_query_types.test';
+
+const DefaultParams = {
+ index: 'index-name',
+ timeField: 'time-field',
+ aggType: 'count',
+ window: '5m',
+};
+
+describe('TimeSeriesParams validate()', () => {
+ runTests(TimeSeriesQuerySchema, DefaultParams);
+
+ let params: any;
+ beforeEach(() => {
+ params = { ...DefaultParams };
+ });
+
+ it('passes for minimal valid input', async () => {
+ expect(validate()).toBeTruthy();
+ });
+
+ it('passes for maximal valid input', async () => {
+ params.aggType = 'average';
+ params.aggField = 'agg-field';
+ params.groupField = 'group-field';
+ params.groupLimit = 100;
+ params.dateStart = new Date().toISOString();
+ params.dateEnd = new Date().toISOString();
+ params.interval = '1s';
+ expect(validate()).toBeTruthy();
+ });
+
+ it('fails for invalid dateStart', async () => {
+ params.dateStart = 42;
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[dateStart]: expected value of type [string] but got [number]"`
+ );
+
+ params.dateStart = 'x';
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(`"[dateStart]: invalid date x"`);
+ });
+
+ it('fails for invalid dateEnd', async () => {
+ params.dateEnd = 42;
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[dateEnd]: expected value of type [string] but got [number]"`
+ );
+
+ params.dateEnd = 'x';
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(`"[dateEnd]: invalid date x"`);
+ });
+
+ it('fails for invalid interval', async () => {
+ params.interval = 42;
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[interval]: expected value of type [string] but got [number]"`
+ );
+
+ params.interval = 'x';
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[interval]: invalid duration: \\"x\\""`
+ );
+ });
+
+ it('fails for dateStart > dateEnd', async () => {
+ params.dateStart = '2021-01-01T00:00:00.000Z';
+ params.dateEnd = '2020-01-01T00:00:00.000Z';
+ params.interval = '1s';
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[dateStart]: is greater than [dateEnd]"`
+ );
+ });
+
+ it('fails for dateStart != dateEnd and no interval', async () => {
+ params.dateStart = '2020-01-01T00:00:00.000Z';
+ params.dateEnd = '2021-01-01T00:00:00.000Z';
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"[interval]: must be specified if [dateStart] does not equal [dateEnd]"`
+ );
+ });
+
+ it('fails for too many intervals', async () => {
+ params.dateStart = '2020-01-01T00:00:00.000Z';
+ params.dateEnd = '2021-01-01T00:00:00.000Z';
+ params.interval = '1s';
+ expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
+ `"calculated number of intervals 31622400 is greater than maximum 1000"`
+ );
+ });
+
+ function onValidate(): () => void {
+ return () => validate();
+ }
+
+ function validate(): any {
+ return TimeSeriesQuerySchema.validate(params);
+ }
+});
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts
new file mode 100644
index 0000000000000..a727e67c621d4
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts
@@ -0,0 +1,106 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+// The parameters and response for the `timeSeriesQuery()` service function,
+// and associated HTTP endpoint.
+
+import { i18n } from '@kbn/i18n';
+import { schema, TypeOf } from '@kbn/config-schema';
+
+import { parseDuration } from '../../../../../alerting/server';
+import { MAX_INTERVALS } from '../index';
+import {
+ CoreQueryParamsSchemaProperties,
+ validateCoreQueryBody,
+ validateDuration,
+} from './core_query_types';
+import {
+ getTooManyIntervalsErrorMessage,
+ getDateStartAfterDateEndErrorMessage,
+} from './date_range_info';
+
+// The result is an object with a key for every field value aggregated
+// via the `aggField` property. If `aggField` is not specified, the
+// object will have a single key of `all documents`. The value associated
+// with each key is an array of 2-tuples of `[ ISO-date, calculated-value ]`
+
+export interface TimeSeriesResult {
+ results: TimeSeriesResultRow[];
+}
+export interface TimeSeriesResultRow {
+ group: string;
+ metrics: MetricResult[];
+}
+export type MetricResult = [string, number]; // [iso date, value]
+
+// The parameters here are very similar to the alert parameters.
+// Missing are `comparator` and `threshold`, which aren't needed to generate
+// data values, only needed when evaluating the data.
+// Additional parameters are used to indicate the date range of the search,
+// and the interval.
+export type TimeSeriesQuery = TypeOf;
+
+export const TimeSeriesQuerySchema = schema.object(
+ {
+ ...CoreQueryParamsSchemaProperties,
+ // start of the date range to search, as an iso string; defaults to dateEnd
+ dateStart: schema.maybe(schema.string({ validate: validateDate })),
+ // end of the date range to search, as an iso string; defaults to now
+ dateEnd: schema.maybe(schema.string({ validate: validateDate })),
+ // intended to be set to the `interval` property of the alert itself,
+ // this value indicates the amount of time between time series dates
+ // that will be calculated.
+ interval: schema.maybe(schema.string({ validate: validateDuration })),
+ },
+ {
+ validate: validateBody,
+ }
+);
+
+// using direct type not allowed, circular reference, so body is typed to any
+function validateBody(anyParams: any): string | undefined {
+ // validate core query parts, return if it fails validation (returning string)
+ const coreQueryValidated = validateCoreQueryBody(anyParams);
+ if (coreQueryValidated) return coreQueryValidated;
+
+ const { dateStart, dateEnd, interval }: TimeSeriesQuery = anyParams;
+
+ // dates already validated in validateDate(), if provided
+ const epochStart = dateStart ? Date.parse(dateStart) : undefined;
+ const epochEnd = dateEnd ? Date.parse(dateEnd) : undefined;
+
+ if (epochStart && epochEnd) {
+ if (epochStart > epochEnd) {
+ return getDateStartAfterDateEndErrorMessage();
+ }
+
+ if (epochStart !== epochEnd && !interval) {
+ return i18n.translate('xpack.alertingBuiltins.indexThreshold.intervalRequiredErrorMessage', {
+ defaultMessage: '[interval]: must be specified if [dateStart] does not equal [dateEnd]',
+ });
+ }
+
+ if (interval) {
+ const intervalMillis = parseDuration(interval);
+ const intervals = Math.round((epochEnd - epochStart) / intervalMillis);
+ if (intervals > MAX_INTERVALS) {
+ return getTooManyIntervalsErrorMessage(intervals, MAX_INTERVALS);
+ }
+ }
+ }
+}
+
+function validateDate(dateString: string): string | undefined {
+ const parsed = Date.parse(dateString);
+ if (isNaN(parsed)) {
+ return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidDateErrorMessage', {
+ defaultMessage: 'invalid date {date}',
+ values: {
+ date: dateString,
+ },
+ });
+ }
+}
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/routes.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/routes.ts
new file mode 100644
index 0000000000000..1aabca8af0715
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/routes.ts
@@ -0,0 +1,53 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ IRouter,
+ RequestHandlerContext,
+ KibanaRequest,
+ IKibanaResponse,
+ KibanaResponseFactory,
+} from 'kibana/server';
+
+import { Service } from '../../types';
+import { TimeSeriesQuery, TimeSeriesQuerySchema, TimeSeriesResult } from './lib/time_series_types';
+export { TimeSeriesQuery, TimeSeriesResult } from './lib/time_series_types';
+
+export function createTimeSeriesQueryRoute(service: Service, router: IRouter, baseRoute: string) {
+ const path = `${baseRoute}/_time_series_query`;
+ service.logger.debug(`registering indexThreshold timeSeriesQuery route POST ${path}`);
+ router.post(
+ {
+ path,
+ validate: {
+ body: TimeSeriesQuerySchema,
+ },
+ },
+ handler
+ );
+ async function handler(
+ ctx: RequestHandlerContext,
+ req: KibanaRequest,
+ res: KibanaResponseFactory
+ ): Promise {
+ service.logger.debug(`route query_data request: ${JSON.stringify(req.body, null, 4)}`);
+
+ let result: TimeSeriesResult;
+ try {
+ result = await service.indexThreshold.timeSeriesQuery({
+ logger: service.logger,
+ callCluster: ctx.core.elasticsearch.dataClient.callAsCurrentUser,
+ query: req.body,
+ });
+ } catch (err) {
+ service.logger.debug(`route query_data error: ${err.message}`);
+ return res.internalError({ body: 'error running time series query' });
+ }
+
+ service.logger.debug(`route query_data response: ${JSON.stringify(result, null, 4)}`);
+ return res.ok({ body: result });
+ }
+}
diff --git a/x-pack/plugins/alerting_builtins/server/config.ts b/x-pack/plugins/alerting_builtins/server/config.ts
new file mode 100644
index 0000000000000..8a13aedd5fdd8
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/config.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { schema, TypeOf } from '@kbn/config-schema';
+
+export const configSchema = schema.object({
+ enabled: schema.boolean({ defaultValue: true }),
+});
+
+export type Config = TypeOf;
diff --git a/x-pack/plugins/alerting_builtins/server/index.ts b/x-pack/plugins/alerting_builtins/server/index.ts
new file mode 100644
index 0000000000000..00613213d5aed
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { PluginInitializerContext } from 'src/core/server';
+import { AlertingBuiltinsPlugin } from './plugin';
+import { configSchema } from './config';
+
+export const plugin = (ctx: PluginInitializerContext) => new AlertingBuiltinsPlugin(ctx);
+
+export const config = {
+ schema: configSchema,
+};
+
+export { IService } from './types';
diff --git a/x-pack/plugins/alerting_builtins/server/plugin.test.ts b/x-pack/plugins/alerting_builtins/server/plugin.test.ts
new file mode 100644
index 0000000000000..6bcf0379d5abe
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/plugin.test.ts
@@ -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 { AlertingBuiltinsPlugin } from './plugin';
+import { coreMock } from '../../../../src/core/server/mocks';
+import { alertsMock } from '../../../plugins/alerting/server/mocks';
+
+describe('AlertingBuiltins Plugin', () => {
+ describe('setup()', () => {
+ let context: ReturnType;
+ let plugin: AlertingBuiltinsPlugin;
+ let coreSetup: ReturnType;
+
+ beforeEach(() => {
+ context = coreMock.createPluginInitializerContext();
+ plugin = new AlertingBuiltinsPlugin(context);
+ coreSetup = coreMock.createSetup();
+ });
+
+ it('should register built-in alert types', async () => {
+ const alertingSetup = alertsMock.createSetup();
+ await plugin.setup(coreSetup, { alerting: alertingSetup });
+
+ expect(alertingSetup.registerType).toHaveBeenCalledTimes(1);
+
+ const args = alertingSetup.registerType.mock.calls[0][0];
+ const testedArgs = { id: args.id, name: args.name, actionGroups: args.actionGroups };
+ expect(testedArgs).toMatchInlineSnapshot(`
+ Object {
+ "actionGroups": Array [
+ Object {
+ "id": "threshold met",
+ "name": "Threshold Met",
+ },
+ ],
+ "id": ".index-threshold",
+ "name": "Index Threshold",
+ }
+ `);
+ });
+
+ it('should return a service in the expected shape', async () => {
+ const alertingSetup = alertsMock.createSetup();
+ const service = await plugin.setup(coreSetup, { alerting: alertingSetup });
+
+ expect(typeof service.indexThreshold.timeSeriesQuery).toBe('function');
+ });
+ });
+
+ describe('start()', () => {
+ let context: ReturnType;
+ let plugin: AlertingBuiltinsPlugin;
+ let coreStart: ReturnType;
+
+ beforeEach(() => {
+ context = coreMock.createPluginInitializerContext();
+ plugin = new AlertingBuiltinsPlugin(context);
+ coreStart = coreMock.createStart();
+ });
+
+ it('should return a service in the expected shape', async () => {
+ const service = await plugin.start(coreStart);
+
+ expect(typeof service.indexThreshold.timeSeriesQuery).toBe('function');
+ });
+ });
+});
diff --git a/x-pack/plugins/alerting_builtins/server/plugin.ts b/x-pack/plugins/alerting_builtins/server/plugin.ts
new file mode 100644
index 0000000000000..9a9483f9c9dfa
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/plugin.ts
@@ -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 { Plugin, Logger, CoreSetup, CoreStart, PluginInitializerContext } from 'src/core/server';
+
+import { Service, IService, AlertingBuiltinsDeps } from './types';
+import { getService as getServiceIndexThreshold } from './alert_types/index_threshold';
+import { registerBuiltInAlertTypes } from './alert_types';
+
+export class AlertingBuiltinsPlugin implements Plugin {
+ private readonly logger: Logger;
+ private readonly service: Service;
+
+ constructor(ctx: PluginInitializerContext) {
+ this.logger = ctx.logger.get();
+ this.service = {
+ indexThreshold: getServiceIndexThreshold(),
+ logger: this.logger,
+ };
+ }
+
+ public async setup(core: CoreSetup, { alerting }: AlertingBuiltinsDeps): Promise {
+ registerBuiltInAlertTypes({
+ service: this.service,
+ router: core.http.createRouter(),
+ alerting,
+ baseRoute: '/api/alerting_builtins',
+ });
+ return this.service;
+ }
+
+ public async start(core: CoreStart): Promise {
+ return this.service;
+ }
+
+ public async stop(): Promise {}
+}
diff --git a/x-pack/plugins/alerting_builtins/server/types.ts b/x-pack/plugins/alerting_builtins/server/types.ts
new file mode 100644
index 0000000000000..ff07b85fd3038
--- /dev/null
+++ b/x-pack/plugins/alerting_builtins/server/types.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Logger, ScopedClusterClient } from '../../../../src/core/server';
+import { PluginSetupContract as AlertingSetup } from '../../alerting/server';
+import { getService as getServiceIndexThreshold } from './alert_types/index_threshold';
+
+export { Logger, IRouter } from '../../../../src/core/server';
+
+export {
+ PluginSetupContract as AlertingSetup,
+ AlertType,
+ AlertExecutorOptions,
+} from '../../alerting/server';
+
+// this plugin's dependendencies
+export interface AlertingBuiltinsDeps {
+ alerting: AlertingSetup;
+}
+
+// external service exposed through plugin setup/start
+export interface IService {
+ indexThreshold: ReturnType;
+}
+
+// version of service for internal use
+export interface Service extends IService {
+ logger: Logger;
+}
+
+export type CallCluster = ScopedClusterClient['callAsCurrentUser'];
diff --git a/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts b/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts
index d409ea4e2615a..ccd7748d9e899 100644
--- a/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts
+++ b/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts
@@ -39,6 +39,16 @@ export class ESTestIndexTool {
enabled: false,
type: 'object',
},
+ date: {
+ type: 'date',
+ format: 'strict_date_time',
+ },
+ testedValue: {
+ type: 'long',
+ },
+ group: {
+ type: 'keyword',
+ },
},
},
},
diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts
new file mode 100644
index 0000000000000..c0147cbedcdfe
--- /dev/null
+++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts
@@ -0,0 +1,14 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { FtrProviderContext } from '../../../../common/ftr_provider_context';
+
+// eslint-disable-next-line import/no-default-export
+export default function alertingTests({ loadTestFile }: FtrProviderContext) {
+ describe('builtin alertTypes', () => {
+ loadTestFile(require.resolve('./index_threshold'));
+ });
+}
diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts
new file mode 100644
index 0000000000000..41c07c428a089
--- /dev/null
+++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts
@@ -0,0 +1,72 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { times } from 'lodash';
+import { v4 as uuid } from 'uuid';
+import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '../../../../../common/lib';
+
+// date to start writing data
+export const START_DATE = '2020-01-01T00:00:00Z';
+
+const DOCUMENT_SOURCE = 'queryDataEndpointTests';
+
+// Create a set of es documents to run the queries against.
+// Will create 2 documents for each interval.
+// The difference between the dates of the docs will be intervalMillis.
+// The date of the last documents will be startDate - intervalMillis / 2.
+// So there will be 2 documents written in the middle of each interval range.
+// The data value written to each doc is a power of 2, with 2^0 as the value
+// of the last documents, the values increasing for older documents. The
+// second document for each time value will be power of 2 + 1
+export async function createEsDocuments(
+ es: any,
+ esTestIndexTool: ESTestIndexTool,
+ startDate: string,
+ intervals: number,
+ intervalMillis: number
+) {
+ const totalDocuments = intervals * 2;
+ const startDateMillis = Date.parse(startDate) - intervalMillis / 2;
+
+ times(intervals, interval => {
+ const date = startDateMillis - interval * intervalMillis;
+
+ // base value for each window is 2^window
+ const testedValue = 2 ** interval;
+
+ // don't need await on these, wait at the end of the function
+ createEsDocument(es, '-na-', date, testedValue, 'groupA');
+ createEsDocument(es, '-na-', date, testedValue + 1, 'groupB');
+ });
+
+ await esTestIndexTool.waitForDocs(DOCUMENT_SOURCE, '-na-', totalDocuments);
+}
+
+async function createEsDocument(
+ es: any,
+ reference: string,
+ epochMillis: number,
+ testedValue: number,
+ group: string
+) {
+ const document = {
+ source: DOCUMENT_SOURCE,
+ reference,
+ date: new Date(epochMillis).toISOString(),
+ testedValue,
+ group,
+ };
+
+ const response = await es.index({
+ id: uuid(),
+ index: ES_TEST_INDEX_NAME,
+ body: document,
+ });
+
+ if (response.result !== 'created') {
+ throw new Error(`document not created: ${JSON.stringify(response)}`);
+ }
+}
diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts
new file mode 100644
index 0000000000000..6fdc68889b66f
--- /dev/null
+++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts
@@ -0,0 +1,14 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
+
+// eslint-disable-next-line import/no-default-export
+export default function alertingTests({ loadTestFile }: FtrProviderContext) {
+ describe('index_threshold', () => {
+ loadTestFile(require.resolve('./query_data_endpoint'));
+ });
+}
diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/query_data_endpoint.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/query_data_endpoint.ts
new file mode 100644
index 0000000000000..9c1a58760be79
--- /dev/null
+++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/query_data_endpoint.ts
@@ -0,0 +1,283 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * 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 { Spaces } from '../../../../scenarios';
+import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
+import { ESTestIndexTool, ES_TEST_INDEX_NAME, getUrlPrefix } from '../../../../../common/lib';
+import { TimeSeriesQuery } from '../../../../../../../plugins/alerting_builtins/server/alert_types/index_threshold/routes';
+
+import { createEsDocuments } from './create_test_data';
+
+const INDEX_THRESHOLD_TIME_SERIES_QUERY_URL =
+ 'api/alerting_builtins/index_threshold/_time_series_query';
+
+const START_DATE_MM_DD_HH_MM_SS_MS = '01-01T00:00:00.000Z';
+const START_DATE = `2020-${START_DATE_MM_DD_HH_MM_SS_MS}`;
+const INTERVALS = 3;
+
+// time length of a window
+const INTERVAL_MINUTES = 1;
+const INTERVAL_DURATION = `${INTERVAL_MINUTES}m`;
+const INTERVAL_MILLIS = INTERVAL_MINUTES * 60 * 1000;
+
+const WINDOW_MINUTES = 5;
+const WINDOW_DURATION = `${WINDOW_MINUTES}m`;
+
+// interesting dates pertaining to docs and intervals
+const START_DATE_PLUS_YEAR = `2021-${START_DATE_MM_DD_HH_MM_SS_MS}`;
+const START_DATE_MINUS_YEAR = `2019-${START_DATE_MM_DD_HH_MM_SS_MS}`;
+const START_DATE_MINUS_0INTERVALS = START_DATE;
+const START_DATE_MINUS_1INTERVALS = getStartDate(-1 * INTERVAL_MILLIS);
+const START_DATE_MINUS_2INTERVALS = getStartDate(-2 * INTERVAL_MILLIS);
+
+/* creates the following documents to run queries over; the documents
+ are offset from the top of the minute by 30 seconds, the queries always
+ run from the top of the hour.
+
+ { "date":"2019-12-31T23:59:30.000Z", "testedValue":1, "group":"groupA" }
+ { "date":"2019-12-31T23:59:30.000Z", "testedValue":2, "group":"groupB" }
+ { "date":"2019-12-31T23:58:30.000Z", "testedValue":2, "group":"groupA" }
+ { "date":"2019-12-31T23:58:30.000Z", "testedValue":3, "group":"groupB" }
+ { "date":"2019-12-31T23:57:30.000Z", "testedValue":4, "group":"groupA" }
+ { "date":"2019-12-31T23:57:30.000Z", "testedValue":5, "group":"groupB" }
+*/
+
+// eslint-disable-next-line import/no-default-export
+export default function queryDataEndpointTests({ getService }: FtrProviderContext) {
+ const supertest = getService('supertest');
+ const retry = getService('retry');
+ const es = getService('legacyEs');
+ const esTestIndexTool = new ESTestIndexTool(es, retry);
+
+ describe('query_data endpoint', () => {
+ before(async () => {
+ await esTestIndexTool.destroy();
+ await esTestIndexTool.setup();
+ // To browse the documents created, comment out esTestIndexTool.destroy() in below, then:
+ // curl http://localhost:9220/.kibaka-alerting-test-data/_search?size=100 | json
+ await createEsDocuments(es, esTestIndexTool, START_DATE, INTERVALS, INTERVAL_MILLIS);
+ });
+
+ after(async () => {
+ await esTestIndexTool.destroy();
+ });
+
+ it('should handle queries before any data available', async () => {
+ const query = getQueryBody({
+ dateStart: undefined,
+ dateEnd: START_DATE_PLUS_YEAR,
+ });
+
+ const expected = {
+ results: [{ group: 'all documents', metrics: [[START_DATE_PLUS_YEAR, 0]] }],
+ };
+
+ expect(await runQueryExpect(query, 200)).eql(expected);
+ });
+
+ it('should handle queries after any data available', async () => {
+ const query = getQueryBody({
+ dateStart: undefined,
+ dateEnd: START_DATE_MINUS_YEAR,
+ });
+
+ const expected = {
+ results: [{ group: 'all documents', metrics: [[START_DATE_MINUS_YEAR, 0]] }],
+ };
+
+ expect(await runQueryExpect(query, 200)).eql(expected);
+ });
+
+ it('should return the current count for 1 interval, not grouped', async () => {
+ const query = getQueryBody({
+ dateStart: START_DATE,
+ dateEnd: START_DATE,
+ });
+
+ const expected = {
+ results: [{ group: 'all documents', metrics: [[START_DATE, 6]] }],
+ };
+
+ expect(await runQueryExpect(query, 200)).eql(expected);
+ });
+
+ it('should return correct count for all intervals, not grouped', async () => {
+ const query = getQueryBody({
+ dateStart: START_DATE_MINUS_2INTERVALS,
+ dateEnd: START_DATE_MINUS_0INTERVALS,
+ });
+
+ const expected = {
+ results: [
+ {
+ group: 'all documents',
+ metrics: [
+ [START_DATE_MINUS_2INTERVALS, 2],
+ [START_DATE_MINUS_1INTERVALS, 4],
+ [START_DATE_MINUS_0INTERVALS, 6],
+ ],
+ },
+ ],
+ };
+
+ expect(await runQueryExpect(query, 200)).eql(expected);
+ });
+
+ it('should return correct min for all intervals, not grouped', async () => {
+ const query = getQueryBody({
+ aggType: 'min',
+ aggField: 'testedValue',
+ dateStart: START_DATE_MINUS_2INTERVALS,
+ dateEnd: START_DATE_MINUS_0INTERVALS,
+ });
+
+ const expected = {
+ results: [
+ {
+ group: 'all documents',
+ metrics: [
+ [START_DATE_MINUS_2INTERVALS, 4],
+ [START_DATE_MINUS_1INTERVALS, 2],
+ [START_DATE_MINUS_0INTERVALS, 1],
+ ],
+ },
+ ],
+ };
+
+ expect(await runQueryExpect(query, 200)).eql(expected);
+ });
+
+ it('should return correct count for all intervals, grouped', async () => {
+ const query = getQueryBody({
+ groupField: 'group',
+ dateStart: START_DATE_MINUS_2INTERVALS,
+ dateEnd: START_DATE_MINUS_0INTERVALS,
+ });
+
+ const expected = {
+ results: [
+ {
+ group: 'groupA',
+ metrics: [
+ [START_DATE_MINUS_2INTERVALS, 1],
+ [START_DATE_MINUS_1INTERVALS, 2],
+ [START_DATE_MINUS_0INTERVALS, 3],
+ ],
+ },
+ {
+ group: 'groupB',
+ metrics: [
+ [START_DATE_MINUS_2INTERVALS, 1],
+ [START_DATE_MINUS_1INTERVALS, 2],
+ [START_DATE_MINUS_0INTERVALS, 3],
+ ],
+ },
+ ],
+ };
+
+ expect(await runQueryExpect(query, 200)).eql(expected);
+ });
+
+ it('should return correct average for all intervals, grouped', async () => {
+ const query = getQueryBody({
+ aggType: 'average',
+ aggField: 'testedValue',
+ groupField: 'group',
+ dateStart: START_DATE_MINUS_2INTERVALS,
+ dateEnd: START_DATE_MINUS_0INTERVALS,
+ });
+
+ const expected = {
+ results: [
+ {
+ group: 'groupA',
+ metrics: [
+ [START_DATE_MINUS_2INTERVALS, 4 / 1],
+ [START_DATE_MINUS_1INTERVALS, (4 + 2) / 2],
+ [START_DATE_MINUS_0INTERVALS, (4 + 2 + 1) / 3],
+ ],
+ },
+ {
+ group: 'groupB',
+ metrics: [
+ [START_DATE_MINUS_2INTERVALS, 5 / 1],
+ [START_DATE_MINUS_1INTERVALS, (5 + 3) / 2],
+ [START_DATE_MINUS_0INTERVALS, (5 + 3 + 2) / 3],
+ ],
+ },
+ ],
+ };
+
+ expect(await runQueryExpect(query, 200)).eql(expected);
+ });
+
+ it('should return an error when passed invalid input', async () => {
+ const query = { ...getQueryBody(), aggType: 'invalid-agg-type' };
+ const expected = {
+ error: 'Bad Request',
+ message: '[request body.aggType]: invalid aggType: "invalid-agg-type"',
+ statusCode: 400,
+ };
+ expect(await runQueryExpect(query, 400)).eql(expected);
+ });
+
+ it('should return an error when too many intervals calculated', async () => {
+ const query = {
+ ...getQueryBody(),
+ dateStart: '2000-01-01T00:00:00.000Z',
+ dateEnd: '2020-01-01T00:00:00.000Z',
+ interval: '1s',
+ };
+ const expected = {
+ error: 'Bad Request',
+ message:
+ '[request body]: calculated number of intervals 631152000 is greater than maximum 1000',
+ statusCode: 400,
+ };
+ expect(await runQueryExpect(query, 400)).eql(expected);
+ });
+ });
+
+ async function runQueryExpect(requestBody: TimeSeriesQuery, status: number): Promise {
+ const url = `${getUrlPrefix(Spaces.space1.id)}/${INDEX_THRESHOLD_TIME_SERIES_QUERY_URL}`;
+ const res = await supertest
+ .post(url)
+ .set('kbn-xsrf', 'foo')
+ .send(requestBody);
+
+ if (res.status !== status) {
+ // good place to put a console log for debugging unexpected results
+ // console.log(res.body)
+ throw new Error(`expected status ${status}, but got ${res.status}`);
+ }
+
+ return res.body;
+ }
+}
+
+function getQueryBody(body: Partial = {}): TimeSeriesQuery {
+ const defaults: TimeSeriesQuery = {
+ index: ES_TEST_INDEX_NAME,
+ timeField: 'date',
+ aggType: 'count',
+ aggField: undefined,
+ groupField: undefined,
+ groupLimit: undefined,
+ dateStart: START_DATE_MINUS_0INTERVALS,
+ dateEnd: undefined,
+ window: WINDOW_DURATION,
+ interval: INTERVAL_DURATION,
+ };
+ return Object.assign({}, defaults, body);
+}
+
+function getStartDate(deltaMillis: number) {
+ const startDateMillis = Date.parse(START_DATE);
+ const returnedDateMillis = startDateMillis + deltaMillis;
+ return new Date(returnedDateMillis).toISOString();
+}
diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts
index 0b7f51ac9a79b..a0c4da361bd38 100644
--- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts
+++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts
@@ -25,5 +25,6 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./update_api_key'));
loadTestFile(require.resolve('./alerts_space1'));
loadTestFile(require.resolve('./alerts_default_space'));
+ loadTestFile(require.resolve('./builtin_alert_types'));
});
}
From fd25ae6505afe088ed2cc58ed6efd392b02a7e47 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?=
Date: Fri, 28 Feb 2020 16:52:35 +0000
Subject: [PATCH 12/31] [Telemetry] Application Usage implemented in
@kbn/analytics (#58401)
* [Telemetry] Report the Application Usage (time of usage + number of clicks)
* Add Unit tests to the server side
* Do not use optional chaining in JS
* Add tests on the public end
* Fix jslint errors
* jest.useFakeTimers() + jest.clearAllTimers()
* Remove Jest timer handlers from my tests (only affecting to a minimum coverage bit)
* Catch ES actions in the setup/start steps because it broke core_services tests
* Fix boolean check
* Use core's ES.adminCLient over .createClient
* Fix tests after ES.adminClient
* [Telemetry] Application Usage implemented in kbn-analytics
* Use bulkCreate in store_report
* ApplicationUsagePluginStart does not exist anymore
* Fix usage_collection mock interface
* Check there is something to store before calling the bulkCreate method
* Add unit tests
* Fix types in tests
* Unit tests for rollTotals and actual fix for the bug found
* Fix usage_collection mock after #57693 got merged
Co-authored-by: Elastic Machine
---
.../src/metrics/application_usage.ts | 56 +++++
packages/kbn-analytics/src/metrics/index.ts | 5 +-
packages/kbn-analytics/src/report.ts | 29 ++-
packages/kbn-analytics/src/reporter.ts | 41 +++-
.../core_plugins/application_usage/index.ts | 31 +++
.../application_usage/mappings.ts | 36 +++
.../application_usage/package.json | 4 +
.../telemetry/common/constants.ts | 5 +
src/legacy/core_plugins/telemetry/index.ts | 11 +-
.../application_usage/index.test.ts | 144 +++++++++++
.../collectors/application_usage/index.ts | 20 ++
.../telemetry_application_usage_collector.ts | 225 ++++++++++++++++++
.../telemetry/server/collectors/index.ts | 1 +
.../core_plugins/telemetry/server/plugin.ts | 18 +-
.../telemetry/server/routes/index.ts | 12 +-
.../server/routes/telemetry_opt_in.ts | 6 +-
.../server/routes/telemetry_opt_in_stats.ts | 6 +-
.../server/routes/telemetry_usage_stats.ts | 8 +-
.../routes/telemetry_user_has_seen_notice.ts | 5 +-
src/legacy/core_plugins/ui_metric/index.ts | 10 +-
.../ui/public/chrome/api/sub_url_hooks.js | 9 +
src/plugins/usage_collection/public/mocks.ts | 3 +
src/plugins/usage_collection/public/plugin.ts | 18 +-
.../public/services/application_usage.test.ts | 69 ++++++
.../public/services/application_usage.ts | 39 +++
src/plugins/usage_collection/server/mocks.ts | 3 -
src/plugins/usage_collection/server/plugin.ts | 21 +-
.../usage_collection/server/report/schema.ts | 9 +
.../server/report/store_report.test.ts | 102 ++++++++
.../server/report/store_report.ts | 44 +++-
.../usage_collection/server/routes/index.ts | 9 +-
.../server/routes/report_metrics.ts | 12 +-
32 files changed, 931 insertions(+), 80 deletions(-)
create mode 100644 packages/kbn-analytics/src/metrics/application_usage.ts
create mode 100644 src/legacy/core_plugins/application_usage/index.ts
create mode 100644 src/legacy/core_plugins/application_usage/mappings.ts
create mode 100644 src/legacy/core_plugins/application_usage/package.json
create mode 100644 src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.test.ts
create mode 100644 src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.ts
create mode 100644 src/legacy/core_plugins/telemetry/server/collectors/application_usage/telemetry_application_usage_collector.ts
create mode 100644 src/plugins/usage_collection/public/services/application_usage.test.ts
create mode 100644 src/plugins/usage_collection/public/services/application_usage.ts
create mode 100644 src/plugins/usage_collection/server/report/store_report.test.ts
diff --git a/packages/kbn-analytics/src/metrics/application_usage.ts b/packages/kbn-analytics/src/metrics/application_usage.ts
new file mode 100644
index 0000000000000..7aea3ba0ef2fc
--- /dev/null
+++ b/packages/kbn-analytics/src/metrics/application_usage.ts
@@ -0,0 +1,56 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import moment, { Moment } from 'moment-timezone';
+import { METRIC_TYPE } from './';
+
+export interface ApplicationUsageCurrent {
+ type: METRIC_TYPE.APPLICATION_USAGE;
+ appId: string;
+ startTime: Moment;
+ numberOfClicks: number;
+}
+
+export class ApplicationUsage {
+ private currentUsage?: ApplicationUsageCurrent;
+
+ public start() {
+ // Count any clicks and assign it to the current app
+ if (window)
+ window.addEventListener(
+ 'click',
+ () => this.currentUsage && this.currentUsage.numberOfClicks++
+ );
+ }
+
+ public appChanged(appId?: string) {
+ const currentUsage = this.currentUsage;
+
+ if (appId) {
+ this.currentUsage = {
+ type: METRIC_TYPE.APPLICATION_USAGE,
+ appId,
+ startTime: moment(),
+ numberOfClicks: 0,
+ };
+ } else {
+ this.currentUsage = void 0;
+ }
+ return currentUsage;
+ }
+}
diff --git a/packages/kbn-analytics/src/metrics/index.ts b/packages/kbn-analytics/src/metrics/index.ts
index ceaf53cbc9753..4fbdddeea90fd 100644
--- a/packages/kbn-analytics/src/metrics/index.ts
+++ b/packages/kbn-analytics/src/metrics/index.ts
@@ -19,15 +19,18 @@
import { UiStatsMetric } from './ui_stats';
import { UserAgentMetric } from './user_agent';
+import { ApplicationUsageCurrent } from './application_usage';
export { UiStatsMetric, createUiStatsMetric, UiStatsMetricType } from './ui_stats';
export { Stats } from './stats';
export { trackUsageAgent } from './user_agent';
+export { ApplicationUsage, ApplicationUsageCurrent } from './application_usage';
-export type Metric = UiStatsMetric | UserAgentMetric;
+export type Metric = UiStatsMetric | UserAgentMetric | ApplicationUsageCurrent;
export enum METRIC_TYPE {
COUNT = 'count',
LOADED = 'loaded',
CLICK = 'click',
USER_AGENT = 'user_agent',
+ APPLICATION_USAGE = 'application_usage',
}
diff --git a/packages/kbn-analytics/src/report.ts b/packages/kbn-analytics/src/report.ts
index 16c0a3069e5fd..58891e48aa3a6 100644
--- a/packages/kbn-analytics/src/report.ts
+++ b/packages/kbn-analytics/src/report.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import moment from 'moment-timezone';
import { UnreachableCaseError, wrapArray } from './util';
import { Metric, Stats, UiStatsMetricType, METRIC_TYPE } from './metrics';
const REPORT_VERSION = 1;
@@ -42,6 +43,13 @@ export interface Report {
appName: string;
}
>;
+ application_usage?: Record<
+ string,
+ {
+ minutesOnScreen: number;
+ numberOfClicks: number;
+ }
+ >;
}
export class ReportManager {
@@ -57,10 +65,11 @@ export class ReportManager {
this.report = ReportManager.createReport();
}
public isReportEmpty(): boolean {
- const { uiStatsMetrics, userAgent } = this.report;
+ const { uiStatsMetrics, userAgent, application_usage: appUsage } = this.report;
const noUiStats = !uiStatsMetrics || Object.keys(uiStatsMetrics).length === 0;
const noUserAgent = !userAgent || Object.keys(userAgent).length === 0;
- return noUiStats && noUserAgent;
+ const noAppUsage = !appUsage || Object.keys(appUsage).length === 0;
+ return noUiStats && noUserAgent && noAppUsage;
}
private incrementStats(count: number, stats?: Stats): Stats {
const { min = 0, max = 0, sum = 0 } = stats || {};
@@ -92,6 +101,8 @@ export class ReportManager {
const { appName, eventName, type } = metric;
return `${appName}-${type}-${eventName}`;
}
+ case METRIC_TYPE.APPLICATION_USAGE:
+ return metric.appId;
default:
throw new UnreachableCaseError(metric);
}
@@ -129,6 +140,20 @@ export class ReportManager {
};
return;
}
+ case METRIC_TYPE.APPLICATION_USAGE:
+ const { numberOfClicks, startTime } = metric;
+ const minutesOnScreen = moment().diff(startTime, 'minutes', true);
+
+ report.application_usage = report.application_usage || {};
+ const appExistingData = report.application_usage[key] || {
+ minutesOnScreen: 0,
+ numberOfClicks: 0,
+ };
+ report.application_usage[key] = {
+ minutesOnScreen: appExistingData.minutesOnScreen + minutesOnScreen,
+ numberOfClicks: appExistingData.numberOfClicks + numberOfClicks,
+ };
+ break;
default:
throw new UnreachableCaseError(metric);
}
diff --git a/packages/kbn-analytics/src/reporter.ts b/packages/kbn-analytics/src/reporter.ts
index 98e29c1e4329e..cbcdf6af63052 100644
--- a/packages/kbn-analytics/src/reporter.ts
+++ b/packages/kbn-analytics/src/reporter.ts
@@ -22,6 +22,7 @@ import { Metric, createUiStatsMetric, trackUsageAgent, UiStatsMetricType } from
import { Storage, ReportStorageManager } from './storage';
import { Report, ReportManager } from './report';
+import { ApplicationUsage } from './metrics';
export interface ReporterConfig {
http: ReportHTTP;
@@ -35,19 +36,22 @@ export type ReportHTTP = (report: Report) => Promise;
export class Reporter {
checkInterval: number;
- private interval: any;
+ private interval?: NodeJS.Timer;
+ private lastAppId?: string;
private http: ReportHTTP;
private reportManager: ReportManager;
private storageManager: ReportStorageManager;
+ private readonly applicationUsage: ApplicationUsage;
private debug: boolean;
private retryCount = 0;
private readonly maxRetries = 3;
+ private started = false;
constructor(config: ReporterConfig) {
const { http, storage, debug, checkInterval = 90000, storageKey = 'analytics' } = config;
this.http = http;
this.checkInterval = checkInterval;
- this.interval = null;
+ this.applicationUsage = new ApplicationUsage();
this.storageManager = new ReportStorageManager(storageKey, storage);
const storedReport = this.storageManager.get();
this.reportManager = new ReportManager(storedReport);
@@ -68,10 +72,34 @@ export class Reporter {
public start = () => {
if (!this.interval) {
this.interval = setTimeout(() => {
- this.interval = null;
+ this.interval = undefined;
this.sendReports();
}, this.checkInterval);
}
+
+ if (this.started) {
+ return;
+ }
+
+ if (window && document) {
+ // Before leaving the page, make sure we store the current usage
+ window.addEventListener('beforeunload', () => this.reportApplicationUsage());
+
+ // Monitoring dashboards might be open in background and we are fine with that
+ // but we don't want to report hours if the user goes to another tab and Kibana is not shown
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible' && this.lastAppId) {
+ this.reportApplicationUsage(this.lastAppId);
+ } else if (document.visibilityState === 'hidden') {
+ this.reportApplicationUsage();
+
+ // We also want to send the report now because intervals and timeouts be stalled when too long in the "hidden" state
+ this.sendReports();
+ }
+ });
+ }
+ this.started = true;
+ this.applicationUsage.start();
};
private log(message: any) {
@@ -102,6 +130,13 @@ export class Reporter {
this.saveToReport([report]);
};
+ public reportApplicationUsage(appId?: string) {
+ this.log(`Reporting application changed to ${appId}`);
+ this.lastAppId = appId || this.lastAppId;
+ const appChangedReport = this.applicationUsage.appChanged(appId);
+ if (appChangedReport) this.saveToReport([appChangedReport]);
+ }
+
public sendReports = async () => {
if (!this.reportManager.isReportEmpty()) {
try {
diff --git a/src/legacy/core_plugins/application_usage/index.ts b/src/legacy/core_plugins/application_usage/index.ts
new file mode 100644
index 0000000000000..752d6eaa19bb0
--- /dev/null
+++ b/src/legacy/core_plugins/application_usage/index.ts
@@ -0,0 +1,31 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Legacy } from '../../../../kibana';
+import { mappings } from './mappings';
+
+// eslint-disable-next-line import/no-default-export
+export default function ApplicationUsagePlugin(kibana: any) {
+ const config: Legacy.PluginSpecOptions = {
+ id: 'application_usage',
+ uiExports: { mappings }, // Needed to define the mappings for the SavedObjects
+ };
+
+ return new kibana.Plugin(config);
+}
diff --git a/src/legacy/core_plugins/application_usage/mappings.ts b/src/legacy/core_plugins/application_usage/mappings.ts
new file mode 100644
index 0000000000000..39adc53f7e9ff
--- /dev/null
+++ b/src/legacy/core_plugins/application_usage/mappings.ts
@@ -0,0 +1,36 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export const mappings = {
+ application_usage_totals: {
+ properties: {
+ appId: { type: 'keyword' },
+ numberOfClicks: { type: 'long' },
+ minutesOnScreen: { type: 'float' },
+ },
+ },
+ application_usage_transactional: {
+ properties: {
+ timestamp: { type: 'date' },
+ appId: { type: 'keyword' },
+ numberOfClicks: { type: 'long' },
+ minutesOnScreen: { type: 'float' },
+ },
+ },
+};
diff --git a/src/legacy/core_plugins/application_usage/package.json b/src/legacy/core_plugins/application_usage/package.json
new file mode 100644
index 0000000000000..5ab10a2f8d237
--- /dev/null
+++ b/src/legacy/core_plugins/application_usage/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "application_usage",
+ "version": "kibana"
+}
\ No newline at end of file
diff --git a/src/legacy/core_plugins/telemetry/common/constants.ts b/src/legacy/core_plugins/telemetry/common/constants.ts
index 52981c04ad34a..b44bf319e6627 100644
--- a/src/legacy/core_plugins/telemetry/common/constants.ts
+++ b/src/legacy/core_plugins/telemetry/common/constants.ts
@@ -66,6 +66,11 @@ export const TELEMETRY_STATS_TYPE = 'telemetry';
*/
export const UI_METRIC_USAGE_TYPE = 'ui_metric';
+/**
+ * Application Usage type
+ */
+export const APPLICATION_USAGE_TYPE = 'application_usage';
+
/**
* Link to Advanced Settings.
*/
diff --git a/src/legacy/core_plugins/telemetry/index.ts b/src/legacy/core_plugins/telemetry/index.ts
index ec70380d83a0a..1e88e7d65cffd 100644
--- a/src/legacy/core_plugins/telemetry/index.ts
+++ b/src/legacy/core_plugins/telemetry/index.ts
@@ -21,7 +21,7 @@ import * as Rx from 'rxjs';
import { resolve } from 'path';
import JoiNamespace from 'joi';
import { Server } from 'hapi';
-import { CoreSetup, PluginInitializerContext } from 'src/core/server';
+import { PluginInitializerContext } from 'src/core/server';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { getConfigPath } from '../../../core/server/path';
// @ts-ignore
@@ -132,11 +132,6 @@ const telemetry = (kibana: any) => {
},
} as PluginInitializerContext;
- const coreSetup = ({
- http: { server },
- log: server.log,
- } as any) as CoreSetup;
-
try {
await handleOldSettings(server);
} catch (err) {
@@ -147,7 +142,9 @@ const telemetry = (kibana: any) => {
usageCollection,
};
- telemetryPlugin(initializerContext).setup(coreSetup, pluginsSetup, server);
+ const npPlugin = telemetryPlugin(initializerContext);
+ await npPlugin.setup(server.newPlatform.setup.core, pluginsSetup, server);
+ await npPlugin.start(server.newPlatform.start.core);
},
});
};
diff --git a/src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.test.ts b/src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.test.ts
new file mode 100644
index 0000000000000..cdfead2dff3c6
--- /dev/null
+++ b/src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.test.ts
@@ -0,0 +1,144 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server';
+import { savedObjectsRepositoryMock } from '../../../../../../core/server/mocks';
+// eslint-disable-next-line @kbn/eslint/no-restricted-paths
+import { CollectorOptions } from '../../../../../../plugins/usage_collection/server/collector/collector';
+
+import { registerApplicationUsageCollector } from './';
+import {
+ ROLL_INDICES_INTERVAL,
+ SAVED_OBJECTS_TOTAL_TYPE,
+ SAVED_OBJECTS_TRANSACTIONAL_TYPE,
+} from './telemetry_application_usage_collector';
+
+describe('telemetry_application_usage', () => {
+ jest.useFakeTimers();
+
+ let collector: CollectorOptions;
+
+ const usageCollectionMock: jest.Mocked = {
+ makeUsageCollector: jest.fn().mockImplementation(config => (collector = config)),
+ registerCollector: jest.fn(),
+ } as any;
+
+ const getUsageCollector = jest.fn();
+ const callCluster = jest.fn();
+
+ beforeAll(() => registerApplicationUsageCollector(usageCollectionMock, getUsageCollector));
+ afterAll(() => jest.clearAllTimers());
+
+ test('registered collector is set', () => {
+ expect(collector).not.toBeUndefined();
+ });
+
+ test('if no savedObjectClient initialised, return undefined', async () => {
+ expect(await collector.fetch(callCluster)).toBeUndefined();
+ jest.runTimersToTime(ROLL_INDICES_INTERVAL);
+ });
+
+ test('when savedObjectClient is initialised, return something', async () => {
+ const savedObjectClient = savedObjectsRepositoryMock.create();
+ savedObjectClient.find.mockImplementation(
+ async () =>
+ ({
+ saved_objects: [],
+ total: 0,
+ } as any)
+ );
+ getUsageCollector.mockImplementation(() => savedObjectClient);
+
+ jest.runTimersToTime(ROLL_INDICES_INTERVAL); // Force rollTotals to run
+
+ expect(await collector.fetch(callCluster)).toStrictEqual({});
+ expect(savedObjectClient.bulkCreate).not.toHaveBeenCalled();
+ });
+
+ test('paging in findAll works', async () => {
+ const savedObjectClient = savedObjectsRepositoryMock.create();
+ let total = 201;
+ savedObjectClient.find.mockImplementation(async opts => {
+ if (opts.type === SAVED_OBJECTS_TOTAL_TYPE) {
+ return {
+ saved_objects: [
+ {
+ id: 'appId',
+ attributes: {
+ appId: 'appId',
+ minutesOnScreen: 10,
+ numberOfClicks: 10,
+ },
+ },
+ ],
+ total: 1,
+ } as any;
+ }
+ if ((opts.page || 1) > 2) {
+ return { saved_objects: [], total };
+ }
+ const doc = {
+ id: 'test-id',
+ attributes: {
+ appId: 'appId',
+ timestamp: new Date().toISOString(),
+ minutesOnScreen: 1,
+ numberOfClicks: 1,
+ },
+ };
+ const savedObjects = new Array(opts.perPage).fill(doc);
+ total = savedObjects.length * 2 + 1;
+ return { saved_objects: savedObjects, total };
+ });
+
+ getUsageCollector.mockImplementation(() => savedObjectClient);
+
+ jest.runTimersToTime(ROLL_INDICES_INTERVAL); // Force rollTotals to run
+
+ expect(await collector.fetch(callCluster)).toStrictEqual({
+ appId: {
+ clicks_total: total - 1 + 10,
+ clicks_30_days: total - 1,
+ clicks_90_days: total - 1,
+ minutes_on_screen_total: total - 1 + 10,
+ minutes_on_screen_30_days: total - 1,
+ minutes_on_screen_90_days: total - 1,
+ },
+ });
+ expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith(
+ [
+ {
+ id: 'appId',
+ type: SAVED_OBJECTS_TOTAL_TYPE,
+ attributes: {
+ appId: 'appId',
+ minutesOnScreen: total - 1 + 10,
+ numberOfClicks: total - 1 + 10,
+ },
+ },
+ ],
+ { overwrite: true }
+ );
+ expect(savedObjectClient.delete).toHaveBeenCalledTimes(total - 1);
+ expect(savedObjectClient.delete).toHaveBeenCalledWith(
+ SAVED_OBJECTS_TRANSACTIONAL_TYPE,
+ 'test-id'
+ );
+ });
+});
diff --git a/src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.ts b/src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.ts
new file mode 100644
index 0000000000000..1dac303880375
--- /dev/null
+++ b/src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.ts
@@ -0,0 +1,20 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export { registerApplicationUsageCollector } from './telemetry_application_usage_collector';
diff --git a/src/legacy/core_plugins/telemetry/server/collectors/application_usage/telemetry_application_usage_collector.ts b/src/legacy/core_plugins/telemetry/server/collectors/application_usage/telemetry_application_usage_collector.ts
new file mode 100644
index 0000000000000..5047ebc4b0454
--- /dev/null
+++ b/src/legacy/core_plugins/telemetry/server/collectors/application_usage/telemetry_application_usage_collector.ts
@@ -0,0 +1,225 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import moment from 'moment';
+import { APPLICATION_USAGE_TYPE } from '../../../common/constants';
+import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server';
+import {
+ ISavedObjectsRepository,
+ SavedObjectAttributes,
+ SavedObjectsFindOptions,
+ SavedObject,
+} from '../../../../../../core/server';
+
+/**
+ * Roll indices every 24h
+ */
+export const ROLL_INDICES_INTERVAL = 24 * 60 * 60 * 1000;
+
+/**
+ * Start rolling indices after 5 minutes up
+ */
+export const ROLL_INDICES_START = 5 * 60 * 1000;
+
+export const SAVED_OBJECTS_TOTAL_TYPE = 'application_usage_totals';
+export const SAVED_OBJECTS_TRANSACTIONAL_TYPE = 'application_usage_transactional';
+
+interface ApplicationUsageTotal extends SavedObjectAttributes {
+ appId: string;
+ minutesOnScreen: number;
+ numberOfClicks: number;
+}
+
+interface ApplicationUsageTransactional extends ApplicationUsageTotal {
+ timestamp: string;
+}
+
+interface ApplicationUsageTelemetryReport {
+ [appId: string]: {
+ clicks_total: number;
+ clicks_30_days: number;
+ clicks_90_days: number;
+ minutes_on_screen_total: number;
+ minutes_on_screen_30_days: number;
+ minutes_on_screen_90_days: number;
+ };
+}
+
+async function findAll(
+ savedObjectsClient: ISavedObjectsRepository,
+ opts: SavedObjectsFindOptions
+): Promise>> {
+ const { page = 1, perPage = 100, ...options } = opts;
+ const { saved_objects: savedObjects, total } = await savedObjectsClient.find({
+ ...options,
+ page,
+ perPage,
+ });
+ if (page * perPage >= total) {
+ return savedObjects;
+ }
+ return [...savedObjects, ...(await findAll(savedObjectsClient, { ...opts, page: page + 1 }))];
+}
+
+export function registerApplicationUsageCollector(
+ usageCollection: UsageCollectionSetup,
+ getSavedObjectsClient: () => ISavedObjectsRepository | undefined
+) {
+ const collector = usageCollection.makeUsageCollector({
+ type: APPLICATION_USAGE_TYPE,
+ isReady: () => typeof getSavedObjectsClient() !== 'undefined',
+ fetch: async () => {
+ const savedObjectsClient = getSavedObjectsClient();
+ if (typeof savedObjectsClient === 'undefined') {
+ return;
+ }
+ const [rawApplicationUsageTotals, rawApplicationUsageTransactional] = await Promise.all([
+ findAll(savedObjectsClient, { type: SAVED_OBJECTS_TOTAL_TYPE }),
+ findAll(savedObjectsClient, {
+ type: SAVED_OBJECTS_TRANSACTIONAL_TYPE,
+ }),
+ ]);
+
+ const applicationUsageFromTotals = rawApplicationUsageTotals.reduce(
+ (acc, { attributes: { appId, minutesOnScreen, numberOfClicks } }) => {
+ const existing = acc[appId] || { clicks_total: 0, minutes_on_screen_total: 0 };
+ return {
+ ...acc,
+ [appId]: {
+ clicks_total: numberOfClicks + existing.clicks_total,
+ clicks_30_days: 0,
+ clicks_90_days: 0,
+ minutes_on_screen_total: minutesOnScreen + existing.minutes_on_screen_total,
+ minutes_on_screen_30_days: 0,
+ minutes_on_screen_90_days: 0,
+ },
+ };
+ },
+ {} as ApplicationUsageTelemetryReport
+ );
+
+ const nowMinus30 = moment().subtract(30, 'days');
+ const nowMinus90 = moment().subtract(90, 'days');
+
+ const applicationUsage = rawApplicationUsageTransactional.reduce(
+ (acc, { attributes: { appId, minutesOnScreen, numberOfClicks, timestamp } }) => {
+ const existing = acc[appId] || {
+ clicks_total: 0,
+ clicks_30_days: 0,
+ clicks_90_days: 0,
+ minutes_on_screen_total: 0,
+ minutes_on_screen_30_days: 0,
+ minutes_on_screen_90_days: 0,
+ };
+
+ const timeOfEntry = moment(timestamp as string);
+ const isInLast30Days = timeOfEntry.isSameOrAfter(nowMinus30);
+ const isInLast90Days = timeOfEntry.isSameOrAfter(nowMinus90);
+
+ const last30Days = {
+ clicks_30_days: existing.clicks_30_days + numberOfClicks,
+ minutes_on_screen_30_days: existing.minutes_on_screen_30_days + minutesOnScreen,
+ };
+ const last90Days = {
+ clicks_90_days: existing.clicks_90_days + numberOfClicks,
+ minutes_on_screen_90_days: existing.minutes_on_screen_90_days + minutesOnScreen,
+ };
+
+ return {
+ ...acc,
+ [appId]: {
+ ...existing,
+ clicks_total: existing.clicks_total + numberOfClicks,
+ minutes_on_screen_total: existing.minutes_on_screen_total + minutesOnScreen,
+ ...(isInLast30Days ? last30Days : {}),
+ ...(isInLast90Days ? last90Days : {}),
+ },
+ };
+ },
+ applicationUsageFromTotals
+ );
+
+ return applicationUsage;
+ },
+ });
+
+ usageCollection.registerCollector(collector);
+
+ setInterval(() => rollTotals(getSavedObjectsClient()), ROLL_INDICES_INTERVAL);
+ setTimeout(() => rollTotals(getSavedObjectsClient()), ROLL_INDICES_START);
+}
+
+async function rollTotals(savedObjectsClient?: ISavedObjectsRepository) {
+ if (!savedObjectsClient) {
+ return;
+ }
+
+ try {
+ const [rawApplicationUsageTotals, rawApplicationUsageTransactional] = await Promise.all([
+ findAll(savedObjectsClient, { type: SAVED_OBJECTS_TOTAL_TYPE }),
+ findAll(savedObjectsClient, {
+ type: SAVED_OBJECTS_TRANSACTIONAL_TYPE,
+ filter: `${SAVED_OBJECTS_TRANSACTIONAL_TYPE}.attributes.timestamp < now-90d`,
+ }),
+ ]);
+
+ const existingTotals = rawApplicationUsageTotals.reduce(
+ (acc, { attributes: { appId, numberOfClicks, minutesOnScreen } }) => {
+ return {
+ ...acc,
+ // No need to sum because there should be 1 document per appId only
+ [appId]: { appId, numberOfClicks, minutesOnScreen },
+ };
+ },
+ {} as Record
+ );
+
+ const totals = rawApplicationUsageTransactional.reduce((acc, { attributes, id }) => {
+ const { appId, numberOfClicks, minutesOnScreen } = attributes;
+
+ const existing = acc[appId] || { minutesOnScreen: 0, numberOfClicks: 0 };
+
+ return {
+ ...acc,
+ [appId]: {
+ appId,
+ numberOfClicks: numberOfClicks + existing.numberOfClicks,
+ minutesOnScreen: minutesOnScreen + existing.minutesOnScreen,
+ },
+ };
+ }, existingTotals);
+
+ await Promise.all([
+ Object.entries(totals).length &&
+ savedObjectsClient.bulkCreate(
+ Object.entries(totals).map(([id, entry]) => ({
+ type: SAVED_OBJECTS_TOTAL_TYPE,
+ id,
+ attributes: entry,
+ })),
+ { overwrite: true }
+ ),
+ ...rawApplicationUsageTransactional.map(
+ ({ id }) => savedObjectsClient.delete(SAVED_OBJECTS_TRANSACTIONAL_TYPE, id) // There is no bulkDelete :(
+ ),
+ ]);
+ } catch (err) {
+ // Silent failure
+ }
+}
diff --git a/src/legacy/core_plugins/telemetry/server/collectors/index.ts b/src/legacy/core_plugins/telemetry/server/collectors/index.ts
index 04ee4773cd60d..6cb7a38b6414f 100644
--- a/src/legacy/core_plugins/telemetry/server/collectors/index.ts
+++ b/src/legacy/core_plugins/telemetry/server/collectors/index.ts
@@ -23,3 +23,4 @@ export { registerUiMetricUsageCollector } from './ui_metric';
export { registerLocalizationUsageCollector } from './localization';
export { registerTelemetryPluginUsageCollector } from './telemetry_plugin';
export { registerManagementUsageCollector } from './management';
+export { registerApplicationUsageCollector } from './application_usage';
diff --git a/src/legacy/core_plugins/telemetry/server/plugin.ts b/src/legacy/core_plugins/telemetry/server/plugin.ts
index b5b53b1daba55..d859c0cfd4678 100644
--- a/src/legacy/core_plugins/telemetry/server/plugin.ts
+++ b/src/legacy/core_plugins/telemetry/server/plugin.ts
@@ -17,7 +17,12 @@
* under the License.
*/
-import { CoreSetup, PluginInitializerContext } from 'src/core/server';
+import {
+ CoreSetup,
+ PluginInitializerContext,
+ ISavedObjectsRepository,
+ CoreStart,
+} from 'src/core/server';
import { Server } from 'hapi';
import { registerRoutes } from './routes';
import { registerCollection } from './telemetry_collection';
@@ -28,6 +33,7 @@ import {
registerLocalizationUsageCollector,
registerTelemetryPluginUsageCollector,
registerManagementUsageCollector,
+ registerApplicationUsageCollector,
} from './collectors';
export interface PluginsSetup {
@@ -36,6 +42,7 @@ export interface PluginsSetup {
export class TelemetryPlugin {
private readonly currentKibanaVersion: string;
+ private savedObjectsClient?: ISavedObjectsRepository;
constructor(initializerContext: PluginInitializerContext) {
this.currentKibanaVersion = initializerContext.env.packageInfo.version;
@@ -45,12 +52,19 @@ export class TelemetryPlugin {
const currentKibanaVersion = this.currentKibanaVersion;
registerCollection();
- registerRoutes({ core, currentKibanaVersion });
+ registerRoutes({ core, currentKibanaVersion, server });
+
+ const getSavedObjectsClient = () => this.savedObjectsClient;
registerTelemetryPluginUsageCollector(usageCollection, server);
registerLocalizationUsageCollector(usageCollection, server);
registerTelemetryUsageCollector(usageCollection, server);
registerUiMetricUsageCollector(usageCollection, server);
registerManagementUsageCollector(usageCollection, server);
+ registerApplicationUsageCollector(usageCollection, getSavedObjectsClient);
+ }
+
+ public start({ savedObjects }: CoreStart) {
+ this.savedObjectsClient = savedObjects.createInternalRepository();
}
}
diff --git a/src/legacy/core_plugins/telemetry/server/routes/index.ts b/src/legacy/core_plugins/telemetry/server/routes/index.ts
index 30c018ca7796d..31ff1682d6806 100644
--- a/src/legacy/core_plugins/telemetry/server/routes/index.ts
+++ b/src/legacy/core_plugins/telemetry/server/routes/index.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import { Legacy } from 'kibana';
import { CoreSetup } from 'src/core/server';
import { registerTelemetryOptInRoutes } from './telemetry_opt_in';
import { registerTelemetryUsageStatsRoutes } from './telemetry_usage_stats';
@@ -26,11 +27,12 @@ import { registerTelemetryUserHasSeenNotice } from './telemetry_user_has_seen_no
interface RegisterRoutesParams {
core: CoreSetup;
currentKibanaVersion: string;
+ server: Legacy.Server;
}
-export function registerRoutes({ core, currentKibanaVersion }: RegisterRoutesParams) {
- registerTelemetryOptInRoutes({ core, currentKibanaVersion });
- registerTelemetryUsageStatsRoutes(core);
- registerTelemetryOptInStatsRoutes(core);
- registerTelemetryUserHasSeenNotice(core);
+export function registerRoutes({ core, currentKibanaVersion, server }: RegisterRoutesParams) {
+ registerTelemetryOptInRoutes({ core, currentKibanaVersion, server });
+ registerTelemetryUsageStatsRoutes(server);
+ registerTelemetryOptInStatsRoutes(server);
+ registerTelemetryUserHasSeenNotice(server);
}
diff --git a/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in.ts b/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in.ts
index 596c5c17c353e..ccbc28f6cbadb 100644
--- a/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in.ts
+++ b/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in.ts
@@ -21,6 +21,7 @@ import Joi from 'joi';
import moment from 'moment';
import { boomify } from 'boom';
import { CoreSetup } from 'src/core/server';
+import { Legacy } from 'kibana';
import { getTelemetryAllowChangingOptInStatus } from '../telemetry_config';
import { sendTelemetryOptInStatus } from './telemetry_opt_in_stats';
@@ -32,14 +33,13 @@ import {
interface RegisterOptInRoutesParams {
core: CoreSetup;
currentKibanaVersion: string;
+ server: Legacy.Server;
}
export function registerTelemetryOptInRoutes({
- core,
+ server,
currentKibanaVersion,
}: RegisterOptInRoutesParams) {
- const { server } = core.http as any;
-
server.route({
method: 'POST',
path: '/api/telemetry/v2/optIn',
diff --git a/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in_stats.ts b/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in_stats.ts
index d3bf6dbb77d7a..e64f3f6ff8a94 100644
--- a/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in_stats.ts
+++ b/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in_stats.ts
@@ -21,7 +21,7 @@
import fetch from 'node-fetch';
import Joi from 'joi';
import moment from 'moment';
-import { CoreSetup } from 'src/core/server';
+import { Legacy } from 'kibana';
import { telemetryCollectionManager, StatsGetterConfig } from '../collection_manager';
interface SendTelemetryOptInStatusConfig {
@@ -45,9 +45,7 @@ export async function sendTelemetryOptInStatus(
});
}
-export function registerTelemetryOptInStatsRoutes(core: CoreSetup) {
- const { server } = core.http as any;
-
+export function registerTelemetryOptInStatsRoutes(server: Legacy.Server) {
server.route({
method: 'POST',
path: '/api/telemetry/v2/clusters/_opt_in_stats',
diff --git a/src/legacy/core_plugins/telemetry/server/routes/telemetry_usage_stats.ts b/src/legacy/core_plugins/telemetry/server/routes/telemetry_usage_stats.ts
index c14314ca4da24..ee3241b0dc2ea 100644
--- a/src/legacy/core_plugins/telemetry/server/routes/telemetry_usage_stats.ts
+++ b/src/legacy/core_plugins/telemetry/server/routes/telemetry_usage_stats.ts
@@ -19,16 +19,14 @@
import Joi from 'joi';
import { boomify } from 'boom';
-import { CoreSetup } from 'src/core/server';
+import { Legacy } from 'kibana';
import { telemetryCollectionManager } from '../collection_manager';
-export function registerTelemetryUsageStatsRoutes(core: CoreSetup) {
- const { server } = core.http as any;
-
+export function registerTelemetryUsageStatsRoutes(server: Legacy.Server) {
server.route({
method: 'POST',
path: '/api/telemetry/v2/clusters/_stats',
- config: {
+ options: {
validate: {
payload: Joi.object({
unencrypted: Joi.bool(),
diff --git a/src/legacy/core_plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts b/src/legacy/core_plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts
index 93416058c3277..665e6d9aaeb75 100644
--- a/src/legacy/core_plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts
+++ b/src/legacy/core_plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts
@@ -19,7 +19,6 @@
import { Legacy } from 'kibana';
import { Request } from 'hapi';
-import { CoreSetup } from 'src/core/server';
import {
TelemetrySavedObject,
TelemetrySavedObjectAttributes,
@@ -34,9 +33,7 @@ const getInternalRepository = (server: Legacy.Server) => {
return internalRepository;
};
-export function registerTelemetryUserHasSeenNotice(core: CoreSetup) {
- const { server }: { server: Legacy.Server } = core.http as any;
-
+export function registerTelemetryUserHasSeenNotice(server: Legacy.Server) {
server.route({
method: 'PUT',
path: '/api/telemetry/v2/userHasSeenNotice',
diff --git a/src/legacy/core_plugins/ui_metric/index.ts b/src/legacy/core_plugins/ui_metric/index.ts
index 86d75a9f1818a..5a4a0ebf1a632 100644
--- a/src/legacy/core_plugins/ui_metric/index.ts
+++ b/src/legacy/core_plugins/ui_metric/index.ts
@@ -18,7 +18,6 @@
*/
import { resolve } from 'path';
-import { Legacy } from '../../../../kibana';
// eslint-disable-next-line import/no-default-export
export default function(kibana: any) {
@@ -29,13 +28,6 @@ export default function(kibana: any) {
uiExports: {
mappings: require('./mappings.json'),
},
- init(server: Legacy.Server) {
- const { getSavedObjectsRepository } = server.savedObjects;
- const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin');
- const internalRepository = getSavedObjectsRepository(callWithInternalUser);
- const { usageCollection } = server.newPlatform.setup.plugins;
-
- usageCollection.registerLegacySavedObjects(internalRepository);
- },
+ init() {},
});
}
diff --git a/src/legacy/ui/public/chrome/api/sub_url_hooks.js b/src/legacy/ui/public/chrome/api/sub_url_hooks.js
index 3ff262f546e3c..27d147b1ffc72 100644
--- a/src/legacy/ui/public/chrome/api/sub_url_hooks.js
+++ b/src/legacy/ui/public/chrome/api/sub_url_hooks.js
@@ -21,6 +21,7 @@ import url from 'url';
import { unhashUrl } from '../../../../../plugins/kibana_utils/public';
import { toastNotifications } from '../../notify/toasts';
+import { npSetup } from '../../new_platform';
export function registerSubUrlHooks(angularModule, internals) {
angularModule.run(($rootScope, Private, $location) => {
@@ -40,6 +41,7 @@ export function registerSubUrlHooks(angularModule, internals) {
function onRouteChange($event) {
if (subUrlRouteFilter($event)) {
+ updateUsage($event);
updateSubUrls();
}
}
@@ -67,6 +69,13 @@ export function registerSubUrlHooks(angularModule, internals) {
});
}
+function updateUsage($event) {
+ const scope = $event.targetScope;
+ const app = scope.chrome.getApp();
+ const appId = app.id === 'kibana' ? scope.getFirstPathSegment() : app.id;
+ if (npSetup.plugins.usageCollection) npSetup.plugins.usageCollection.__LEGACY.appChanged(appId);
+}
+
/**
* Creates a function that will be called on each route change
* to determine if the event should be used to update the last
diff --git a/src/plugins/usage_collection/public/mocks.ts b/src/plugins/usage_collection/public/mocks.ts
index 69fbf56ca5604..cc2cfcfd8f661 100644
--- a/src/plugins/usage_collection/public/mocks.ts
+++ b/src/plugins/usage_collection/public/mocks.ts
@@ -26,6 +26,9 @@ const createSetupContract = (): Setup => {
allowTrackUserAgent: jest.fn(),
reportUiStats: jest.fn(),
METRIC_TYPE,
+ __LEGACY: {
+ appChanged: jest.fn(),
+ },
};
return setupContract;
diff --git a/src/plugins/usage_collection/public/plugin.ts b/src/plugins/usage_collection/public/plugin.ts
index 7f80076a483b4..e89e24e25c627 100644
--- a/src/plugins/usage_collection/public/plugin.ts
+++ b/src/plugins/usage_collection/public/plugin.ts
@@ -18,6 +18,7 @@
*/
import { Reporter, METRIC_TYPE } from '@kbn/analytics';
+import { Subject, merge } from 'rxjs';
import { Storage } from '../../kibana_utils/public';
import { createReporter } from './services';
import {
@@ -27,6 +28,7 @@ import {
CoreStart,
HttpSetup,
} from '../../../core/public';
+import { reportApplicationUsage } from './services/application_usage';
interface PublicConfigType {
uiMetric: {
@@ -39,6 +41,15 @@ export interface UsageCollectionSetup {
allowTrackUserAgent: (allow: boolean) => void;
reportUiStats: Reporter['reportUiStats'];
METRIC_TYPE: typeof METRIC_TYPE;
+ __LEGACY: {
+ /**
+ * Legacy handler so we can report the actual app being used inside "kibana#/{appId}".
+ * To be removed when we get rid of the legacy world
+ *
+ * @deprecated
+ */
+ appChanged: (appId: string) => void;
+ };
}
export function isUnauthenticated(http: HttpSetup) {
@@ -47,6 +58,7 @@ export function isUnauthenticated(http: HttpSetup) {
}
export class UsageCollectionPlugin implements Plugin {
+ private readonly legacyAppId$ = new Subject();
private trackUserAgent: boolean = true;
private reporter?: Reporter;
private config: PublicConfigType;
@@ -70,10 +82,13 @@ export class UsageCollectionPlugin implements Plugin {
},
reportUiStats: this.reporter.reportUiStats,
METRIC_TYPE,
+ __LEGACY: {
+ appChanged: appId => this.legacyAppId$.next(appId),
+ },
};
}
- public start({ http }: CoreStart) {
+ public start({ http, application }: CoreStart) {
if (!this.reporter) {
return;
}
@@ -85,6 +100,7 @@ export class UsageCollectionPlugin implements Plugin {
if (this.trackUserAgent) {
this.reporter.reportUserAgent('kibana');
}
+ reportApplicationUsage(merge(application.currentAppId$, this.legacyAppId$), this.reporter);
}
public stop() {}
diff --git a/src/plugins/usage_collection/public/services/application_usage.test.ts b/src/plugins/usage_collection/public/services/application_usage.test.ts
new file mode 100644
index 0000000000000..b314d6cf6472c
--- /dev/null
+++ b/src/plugins/usage_collection/public/services/application_usage.test.ts
@@ -0,0 +1,69 @@
+/*
+ * 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 { Reporter } from '@kbn/analytics';
+import { Subject } from 'rxjs';
+
+import { reportApplicationUsage } from './application_usage';
+
+describe('application_usage', () => {
+ test('report an appId change', () => {
+ const reporterMock: jest.Mocked = {
+ reportApplicationUsage: jest.fn(),
+ } as any;
+
+ const currentAppId$ = new Subject();
+ reportApplicationUsage(currentAppId$, reporterMock);
+
+ currentAppId$.next('appId');
+
+ expect(reporterMock.reportApplicationUsage).toHaveBeenCalledWith('appId');
+ expect(reporterMock.reportApplicationUsage).toHaveBeenCalledTimes(1);
+ });
+
+ test('skip duplicates', () => {
+ const reporterMock: jest.Mocked = {
+ reportApplicationUsage: jest.fn(),
+ } as any;
+
+ const currentAppId$ = new Subject();
+ reportApplicationUsage(currentAppId$, reporterMock);
+
+ currentAppId$.next('appId');
+ currentAppId$.next('appId');
+
+ expect(reporterMock.reportApplicationUsage).toHaveBeenCalledWith('appId');
+ expect(reporterMock.reportApplicationUsage).toHaveBeenCalledTimes(1);
+ });
+
+ test('skip if not a valid value', () => {
+ const reporterMock: jest.Mocked = {
+ reportApplicationUsage: jest.fn(),
+ } as any;
+
+ const currentAppId$ = new Subject();
+ reportApplicationUsage(currentAppId$, reporterMock);
+
+ currentAppId$.next('');
+ currentAppId$.next('kibana');
+ currentAppId$.next(undefined);
+
+ expect(reporterMock.reportApplicationUsage).toHaveBeenCalledTimes(0);
+ });
+});
diff --git a/src/plugins/usage_collection/public/services/application_usage.ts b/src/plugins/usage_collection/public/services/application_usage.ts
new file mode 100644
index 0000000000000..15aaabc70ed0d
--- /dev/null
+++ b/src/plugins/usage_collection/public/services/application_usage.ts
@@ -0,0 +1,39 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Observable } from 'rxjs';
+import { filter, distinctUntilChanged } from 'rxjs/operators';
+import { Reporter } from '@kbn/analytics';
+
+/**
+ * List of appIds not to report usage from (due to legacy hacks)
+ */
+const DO_NOT_REPORT = ['kibana'];
+
+export function reportApplicationUsage(
+ currentAppId$: Observable,
+ reporter: Reporter
+) {
+ currentAppId$
+ .pipe(
+ filter(appId => typeof appId === 'string' && !DO_NOT_REPORT.includes(appId)),
+ distinctUntilChanged()
+ )
+ .subscribe(appId => appId && reporter.reportApplicationUsage(appId));
+}
diff --git a/src/plugins/usage_collection/server/mocks.ts b/src/plugins/usage_collection/server/mocks.ts
index 2194b1fb83f6e..ca3710c62cd89 100644
--- a/src/plugins/usage_collection/server/mocks.ts
+++ b/src/plugins/usage_collection/server/mocks.ts
@@ -27,9 +27,6 @@ const createSetupContract = () => {
logger: loggingServiceMock.createLogger(),
maximumWaitTimeForAllCollectorsInS: 1,
}),
- registerLegacySavedObjects: jest.fn() as jest.Mocked<
- UsageCollectionSetup['registerLegacySavedObjects']
- >,
} as UsageCollectionSetup;
};
diff --git a/src/plugins/usage_collection/server/plugin.ts b/src/plugins/usage_collection/server/plugin.ts
index 5c5b58ae84936..52acb5b3fc86f 100644
--- a/src/plugins/usage_collection/server/plugin.ts
+++ b/src/plugins/usage_collection/server/plugin.ts
@@ -18,18 +18,16 @@
*/
import { first } from 'rxjs/operators';
+import { CoreStart, ISavedObjectsRepository } from 'kibana/server';
import { ConfigType } from './config';
import { PluginInitializerContext, Logger, CoreSetup } from '../../../../src/core/server';
import { CollectorSet } from './collector';
import { setupRoutes } from './routes';
-export type UsageCollectionSetup = CollectorSet & {
- registerLegacySavedObjects: (legacySavedObjects: any) => void;
-};
-
+export type UsageCollectionSetup = CollectorSet;
export class UsageCollectionPlugin {
logger: Logger;
- private legacySavedObjects: any;
+ private savedObjects?: ISavedObjectsRepository;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.logger = this.initializerContext.logger.get();
}
@@ -46,19 +44,14 @@ export class UsageCollectionPlugin {
});
const router = core.http.createRouter();
- const getLegacySavedObjects = () => this.legacySavedObjects;
- setupRoutes(router, getLegacySavedObjects);
+ setupRoutes(router, () => this.savedObjects);
- return {
- ...collectorSet,
- registerLegacySavedObjects: (legacySavedObjects: any) => {
- this.legacySavedObjects = legacySavedObjects;
- },
- };
+ return collectorSet;
}
- public start() {
+ public start({ savedObjects }: CoreStart) {
this.logger.debug('Starting plugin');
+ this.savedObjects = savedObjects.createInternalRepository();
}
public stop() {
diff --git a/src/plugins/usage_collection/server/report/schema.ts b/src/plugins/usage_collection/server/report/schema.ts
index 5adf7d6575a70..a8081e3e320e9 100644
--- a/src/plugins/usage_collection/server/report/schema.ts
+++ b/src/plugins/usage_collection/server/report/schema.ts
@@ -54,6 +54,15 @@ export const reportSchema = schema.object({
})
)
),
+ application_usage: schema.maybe(
+ schema.recordOf(
+ schema.string(),
+ schema.object({
+ minutesOnScreen: schema.number(),
+ numberOfClicks: schema.number(),
+ })
+ )
+ ),
});
export type ReportSchemaType = TypeOf;
diff --git a/src/plugins/usage_collection/server/report/store_report.test.ts b/src/plugins/usage_collection/server/report/store_report.test.ts
new file mode 100644
index 0000000000000..29b6d79cc139a
--- /dev/null
+++ b/src/plugins/usage_collection/server/report/store_report.test.ts
@@ -0,0 +1,102 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { savedObjectsRepositoryMock } from '../../../../core/server/mocks';
+import { storeReport } from './store_report';
+import { ReportSchemaType } from './schema';
+import { METRIC_TYPE } from '../../public';
+
+describe('store_report', () => {
+ test('stores report for all types of data', async () => {
+ const savedObjectClient = savedObjectsRepositoryMock.create();
+ const report: ReportSchemaType = {
+ reportVersion: 1,
+ userAgent: {
+ 'key-user-agent': {
+ key: 'test-key',
+ type: METRIC_TYPE.USER_AGENT,
+ appName: 'test-app-name',
+ userAgent: 'test-user-agent',
+ },
+ },
+ uiStatsMetrics: {
+ any: {
+ key: 'test-key',
+ type: METRIC_TYPE.CLICK,
+ appName: 'test-app-name',
+ eventName: 'test-event-name',
+ stats: {
+ min: 1,
+ max: 2,
+ avg: 1.5,
+ sum: 3,
+ },
+ },
+ },
+ application_usage: {
+ appId: {
+ numberOfClicks: 3,
+ minutesOnScreen: 10,
+ },
+ },
+ };
+ await storeReport(savedObjectClient, report);
+
+ expect(savedObjectClient.create).toHaveBeenCalledWith(
+ 'ui-metric',
+ { count: 1 },
+ {
+ id: 'key-user-agent:test-user-agent',
+ overwrite: true,
+ }
+ );
+ expect(savedObjectClient.incrementCounter).toHaveBeenCalledWith(
+ 'ui-metric',
+ 'test-app-name:test-event-name',
+ 'count'
+ );
+ expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith([
+ {
+ type: 'application_usage_transactional',
+ attributes: {
+ numberOfClicks: 3,
+ minutesOnScreen: 10,
+ appId: 'appId',
+ timestamp: expect.any(Date),
+ },
+ },
+ ]);
+ });
+
+ test('it should not fail if nothing to store', async () => {
+ const savedObjectClient = savedObjectsRepositoryMock.create();
+ const report: ReportSchemaType = {
+ reportVersion: 1,
+ userAgent: void 0,
+ uiStatsMetrics: void 0,
+ application_usage: void 0,
+ };
+ await storeReport(savedObjectClient, report);
+
+ expect(savedObjectClient.bulkCreate).not.toHaveBeenCalled();
+ expect(savedObjectClient.incrementCounter).not.toHaveBeenCalled();
+ expect(savedObjectClient.create).not.toHaveBeenCalled();
+ expect(savedObjectClient.create).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/plugins/usage_collection/server/report/store_report.ts b/src/plugins/usage_collection/server/report/store_report.ts
index 9232a23d6151b..c40622831eeee 100644
--- a/src/plugins/usage_collection/server/report/store_report.ts
+++ b/src/plugins/usage_collection/server/report/store_report.ts
@@ -17,28 +17,50 @@
* under the License.
*/
+import { ISavedObjectsRepository, SavedObject } from 'kibana/server';
import { ReportSchemaType } from './schema';
-export async function storeReport(internalRepository: any, report: ReportSchemaType) {
+export async function storeReport(
+ internalRepository: ISavedObjectsRepository,
+ report: ReportSchemaType
+) {
const uiStatsMetrics = report.uiStatsMetrics ? Object.entries(report.uiStatsMetrics) : [];
const userAgents = report.userAgent ? Object.entries(report.userAgent) : [];
- return Promise.all([
+ const appUsage = report.application_usage ? Object.entries(report.application_usage) : [];
+ const timestamp = new Date();
+ return Promise.all<{ saved_objects: Array> }>([
...userAgents.map(async ([key, metric]) => {
const { userAgent } = metric;
const savedObjectId = `${key}:${userAgent}`;
- return await internalRepository.create(
- 'ui-metric',
- { count: 1 },
- {
- id: savedObjectId,
- overwrite: true,
- }
- );
+ return {
+ saved_objects: [
+ await internalRepository.create(
+ 'ui-metric',
+ { count: 1 },
+ {
+ id: savedObjectId,
+ overwrite: true,
+ }
+ ),
+ ],
+ };
}),
...uiStatsMetrics.map(async ([key, metric]) => {
const { appName, eventName } = metric;
const savedObjectId = `${appName}:${eventName}`;
- return await internalRepository.incrementCounter('ui-metric', savedObjectId, 'count');
+ return {
+ saved_objects: [
+ await internalRepository.incrementCounter('ui-metric', savedObjectId, 'count'),
+ ],
+ };
}),
+ appUsage.length
+ ? internalRepository.bulkCreate(
+ appUsage.map(([appId, metric]) => ({
+ type: 'application_usage_transactional',
+ attributes: { ...metric, appId, timestamp },
+ }))
+ )
+ : { saved_objects: [] },
]);
}
diff --git a/src/plugins/usage_collection/server/routes/index.ts b/src/plugins/usage_collection/server/routes/index.ts
index 9e0d74add57bd..e6beef3fbdc59 100644
--- a/src/plugins/usage_collection/server/routes/index.ts
+++ b/src/plugins/usage_collection/server/routes/index.ts
@@ -17,9 +17,12 @@
* under the License.
*/
-import { IRouter } from '../../../../../src/core/server';
+import { IRouter, ISavedObjectsRepository } from 'kibana/server';
import { registerUiMetricRoute } from './report_metrics';
-export function setupRoutes(router: IRouter, getLegacySavedObjects: any) {
- registerUiMetricRoute(router, getLegacySavedObjects);
+export function setupRoutes(
+ router: IRouter,
+ getSavedObjects: () => ISavedObjectsRepository | undefined
+) {
+ registerUiMetricRoute(router, getSavedObjects);
}
diff --git a/src/plugins/usage_collection/server/routes/report_metrics.ts b/src/plugins/usage_collection/server/routes/report_metrics.ts
index 93f03ea8067d2..a72222968eabf 100644
--- a/src/plugins/usage_collection/server/routes/report_metrics.ts
+++ b/src/plugins/usage_collection/server/routes/report_metrics.ts
@@ -18,10 +18,13 @@
*/
import { schema } from '@kbn/config-schema';
-import { IRouter } from '../../../../../src/core/server';
+import { IRouter, ISavedObjectsRepository } from 'kibana/server';
import { storeReport, reportSchema } from '../report';
-export function registerUiMetricRoute(router: IRouter, getLegacySavedObjects: () => any) {
+export function registerUiMetricRoute(
+ router: IRouter,
+ getSavedObjects: () => ISavedObjectsRepository | undefined
+) {
router.post(
{
path: '/api/ui_metric/report',
@@ -34,7 +37,10 @@ export function registerUiMetricRoute(router: IRouter, getLegacySavedObjects: ()
async (context, req, res) => {
const { report } = req.body;
try {
- const internalRepository = getLegacySavedObjects();
+ const internalRepository = getSavedObjects();
+ if (!internalRepository) {
+ throw Error(`The saved objects client hasn't been initialised yet`);
+ }
await storeReport(internalRepository, report);
return res.ok({ body: { status: 'ok' } });
} catch (error) {
From c5d17acab6c4b5ebdcab60dab8fdecde1e3d0881 Mon Sep 17 00:00:00 2001
From: Brandon Kobel
Date: Fri, 28 Feb 2020 08:58:24 -0800
Subject: [PATCH 13/31] Fix privileges flaky test because the order in arrays
matters for equality (#58790)
Co-authored-by: Elastic Machine
---
.../apis/security/privileges.ts | 84 ++++++++++++-------
1 file changed, 54 insertions(+), 30 deletions(-)
diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts
index 81cffaac07285..4068b88cd30bc 100644
--- a/x-pack/test/api_integration/apis/security/privileges.ts
+++ b/x-pack/test/api_integration/apis/security/privileges.ts
@@ -3,47 +3,71 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
-
+import util from 'util';
+import { isEqual } from 'lodash';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
- // FLAKY: https://github.com/elastic/kibana/issues/58785
- describe.skip('Privileges', () => {
+ describe('Privileges', () => {
describe('GET /api/security/privileges', () => {
it('should return a privilege map with all known privileges, without actions', async () => {
+ // If you're adding a privilege to the following, that's great!
+ // If you're removing a privilege, this breaks backwards compatibility
+ // Roles are associated with these privileges, and we shouldn't be removing them in a minor version.
+ const expected = {
+ features: {
+ discover: ['all', 'read'],
+ visualize: ['all', 'read'],
+ dashboard: ['all', 'read'],
+ dev_tools: ['all', 'read'],
+ advancedSettings: ['all', 'read'],
+ indexPatterns: ['all', 'read'],
+ savedObjectsManagement: ['all', 'read'],
+ timelion: ['all', 'read'],
+ graph: ['all', 'read'],
+ maps: ['all', 'read'],
+ canvas: ['all', 'read'],
+ infrastructure: ['all', 'read'],
+ logs: ['all', 'read'],
+ uptime: ['all', 'read'],
+ apm: ['all', 'read'],
+ siem: ['all', 'read'],
+ endpoint: ['all', 'read'],
+ },
+ global: ['all', 'read'],
+ space: ['all', 'read'],
+ reserved: ['ml', 'monitoring'],
+ };
+
await supertest
.get('/api/security/privileges')
.set('kbn-xsrf', 'xxx')
.send()
- .expect(200, {
- // If you're adding a privilege to the following, that's great!
- // If you're removing a privilege, this breaks backwards compatibility
- // Roles are associated with these privileges, and we shouldn't be removing them in a minor version.
- features: {
- discover: ['all', 'read'],
- visualize: ['all', 'read'],
- dashboard: ['all', 'read'],
- dev_tools: ['all', 'read'],
- advancedSettings: ['all', 'read'],
- indexPatterns: ['all', 'read'],
- savedObjectsManagement: ['all', 'read'],
- timelion: ['all', 'read'],
- graph: ['all', 'read'],
- maps: ['all', 'read'],
- canvas: ['all', 'read'],
- infrastructure: ['all', 'read'],
- logs: ['all', 'read'],
- uptime: ['all', 'read'],
- apm: ['all', 'read'],
- siem: ['all', 'read'],
- endpoint: ['all', 'read'],
- },
- global: ['all', 'read'],
- space: ['all', 'read'],
- reserved: ['monitoring', 'ml'],
- });
+ .expect(200)
+ .expect((res: any) => {
+ // when comparing privileges, the order of the privileges doesn't matter.
+ // supertest uses assert.deepStrictEqual.
+ // expect.js doesn't help us here.
+ // and lodash's isEqual doesn't know how to compare Sets.
+ const success = isEqual(res.body, expected, (value, other, key) => {
+ if (Array.isArray(value) && Array.isArray(other)) {
+ return isEqual(value.sort(), other.sort());
+ }
+
+ // Lodash types aren't correct, `undefined` should be supported as a return value here and it
+ // has special meaning.
+ return undefined as any;
+ });
+
+ if (!success) {
+ throw new Error(
+ `Expected ${util.inspect(res.body)} to equal ${util.inspect(expected)}`
+ );
+ }
+ })
+ .expect(200);
});
});
});
From ad0aa1229605ef23e9fc436aef17ffc0aac93b0b Mon Sep 17 00:00:00 2001
From: Stacey Gammon
Date: Fri, 28 Feb 2020 12:12:03 -0500
Subject: [PATCH 14/31] Improve action and trigger types (#58657)
* Improve types so emitting the wrong context shape complains, as does using a trigger id that has not been added to the trigger context mapping.
* remove unneccessary code
---
.../public/hello_world_action.tsx | 9 +-
examples/ui_action_examples/public/plugin.ts | 28 ++--
.../public/actions/actions.tsx | 30 ++--
examples/ui_actions_explorer/public/app.tsx | 2 +-
.../ui_actions_explorer/public/plugin.tsx | 11 ++
.../public/trigger_context_example.tsx | 9 +-
.../np_ready/embeddable/search_embeddable.ts | 4 +-
.../embeddable/search_embeddable_factory.ts | 4 +-
src/plugins/embeddable/public/bootstrap.ts | 7 +-
.../lib/containers/embeddable_child_panel.tsx | 4 +-
.../lib/panel/embeddable_panel.test.tsx | 2 +-
.../public/lib/panel/embeddable_panel.tsx | 24 ++--
.../lib/panel/panel_header/panel_header.tsx | 5 +-
.../test_samples/actions/edit_mode_action.ts | 8 +-
.../embeddables/contact_card/contact_card.tsx | 12 +-
.../contact_card/contact_card_embeddable.tsx | 4 +-
.../contact_card_embeddable_factory.tsx | 4 +-
.../slow_contact_card_embeddable_factory.ts | 4 +-
.../embeddables/hello_world_container.tsx | 4 +-
.../hello_world_container_component.tsx | 4 +-
.../public/lib/triggers/triggers.ts | 4 +
.../ui_actions/public/actions/action.ts | 14 +-
.../public/actions/create_action.ts | 8 +-
src/plugins/ui_actions/public/index.ts | 19 +--
src/plugins/ui_actions/public/mocks.ts | 3 +-
.../public/service/ui_actions_service.test.ts | 128 ++++++++++--------
.../public/service/ui_actions_service.ts | 42 ++++--
.../tests/execute_trigger_actions.test.ts | 23 ++--
.../public/tests/get_trigger_actions.test.ts | 13 +-
.../get_trigger_compatible_actions.test.ts | 21 +--
.../public/triggers/trigger_contract.ts | 5 +-
.../public/triggers/trigger_internal.ts | 15 +-
src/plugins/ui_actions/public/types.ts | 15 +-
.../public/np_ready/public/app/app.tsx | 4 +-
.../app/dashboard_container_example.tsx | 4 +-
35 files changed, 278 insertions(+), 219 deletions(-)
diff --git a/examples/ui_action_examples/public/hello_world_action.tsx b/examples/ui_action_examples/public/hello_world_action.tsx
index e07855a6f422c..f4c3bfeee6a6d 100644
--- a/examples/ui_action_examples/public/hello_world_action.tsx
+++ b/examples/ui_action_examples/public/hello_world_action.tsx
@@ -24,11 +24,16 @@ import { toMountPoint } from '../../../src/plugins/kibana_react/public';
export const HELLO_WORLD_ACTION_TYPE = 'HELLO_WORLD_ACTION_TYPE';
-export const createHelloWorldAction = (openModal: OverlayStart['openModal']) =>
- createAction<{}>({
+interface StartServices {
+ openModal: OverlayStart['openModal'];
+}
+
+export const createHelloWorldAction = (getStartServices: () => Promise) =>
+ createAction({
type: HELLO_WORLD_ACTION_TYPE,
getDisplayName: () => 'Hello World!',
execute: async () => {
+ const { openModal } = await getStartServices();
const overlay = openModal(
toMountPoint(
diff --git a/examples/ui_action_examples/public/plugin.ts b/examples/ui_action_examples/public/plugin.ts
index bf62b4d973d4d..08b65714dbf66 100644
--- a/examples/ui_action_examples/public/plugin.ts
+++ b/examples/ui_action_examples/public/plugin.ts
@@ -17,30 +17,34 @@
* under the License.
*/
-import { Plugin, CoreSetup, CoreStart } from '../../../src/core/public';
-import { UiActionsSetup, UiActionsStart } from '../../../src/plugins/ui_actions/public';
-import { createHelloWorldAction, HELLO_WORLD_ACTION_TYPE } from './hello_world_action';
-import { helloWorldTrigger } from './hello_world_trigger';
+import { Plugin, CoreSetup } from '../../../src/core/public';
+import { UiActionsSetup } from '../../../src/plugins/ui_actions/public';
+import { createHelloWorldAction } from './hello_world_action';
+import { helloWorldTrigger, HELLO_WORLD_TRIGGER_ID } from './hello_world_trigger';
interface UiActionExamplesSetupDependencies {
uiActions: UiActionsSetup;
}
-interface UiActionExamplesStartDependencies {
- uiActions: UiActionsStart;
+declare module '../../../src/plugins/ui_actions/public' {
+ export interface TriggerContextMapping {
+ [HELLO_WORLD_TRIGGER_ID]: undefined;
+ }
}
export class UiActionExamplesPlugin
- implements
- Plugin {
+ implements Plugin {
public setup(core: CoreSetup, { uiActions }: UiActionExamplesSetupDependencies) {
uiActions.registerTrigger(helloWorldTrigger);
- uiActions.attachAction(helloWorldTrigger.id, HELLO_WORLD_ACTION_TYPE);
- }
- public start(coreStart: CoreStart, deps: UiActionExamplesStartDependencies) {
- deps.uiActions.registerAction(createHelloWorldAction(coreStart.overlays.openModal));
+ const helloWorldAction = createHelloWorldAction(async () => ({
+ openModal: (await core.getStartServices())[0].overlays.openModal,
+ }));
+
+ uiActions.registerAction(helloWorldAction);
+ uiActions.attachAction(helloWorldTrigger.id, helloWorldAction.id);
}
+ public start() {}
public stop() {}
}
diff --git a/examples/ui_actions_explorer/public/actions/actions.tsx b/examples/ui_actions_explorer/public/actions/actions.tsx
index 821a1205861e6..2770b0e3bd5ff 100644
--- a/examples/ui_actions_explorer/public/actions/actions.tsx
+++ b/examples/ui_actions_explorer/public/actions/actions.tsx
@@ -34,16 +34,18 @@ export const EDIT_USER_ACTION = 'EDIT_USER_ACTION';
export const PHONE_USER_ACTION = 'PHONE_USER_ACTION';
export const SHOWCASE_PLUGGABILITY_ACTION = 'SHOWCASE_PLUGGABILITY_ACTION';
-export const showcasePluggability = createAction<{}>({
+export const showcasePluggability = createAction({
type: SHOWCASE_PLUGGABILITY_ACTION,
getDisplayName: () => 'This is pluggable! Any plugin can inject their actions here.',
- execute: async ({}) => alert("Isn't that cool?!"),
+ execute: async () => alert("Isn't that cool?!"),
});
-export const makePhoneCallAction = createAction<{ phone: string }>({
+export type PhoneContext = string;
+
+export const makePhoneCallAction = createAction({
type: CALL_PHONE_NUMBER_ACTION,
getDisplayName: () => 'Call phone number',
- execute: async ({ phone }) => alert(`Pretend calling ${phone}...`),
+ execute: async phone => alert(`Pretend calling ${phone}...`),
});
export const lookUpWeatherAction = createAction<{ country: string }>({
@@ -55,11 +57,13 @@ export const lookUpWeatherAction = createAction<{ country: string }>({
},
});
-export const viewInMapsAction = createAction<{ country: string }>({
+export type CountryContext = string;
+
+export const viewInMapsAction = createAction({
type: VIEW_IN_MAPS_ACTION,
getIconType: () => 'popout',
getDisplayName: () => 'View in maps',
- execute: async ({ country }) => {
+ execute: async country => {
window.open(`https://www.google.com/maps/place/${country}`, '_blank');
},
});
@@ -110,11 +114,13 @@ export const createEditUserAction = (getOpenModal: () => Promise void;
+}
+
export const createPhoneUserAction = (getUiActionsApi: () => Promise) =>
- createAction<{
- user: User;
- update: (user: User) => void;
- }>({
+ createAction({
type: PHONE_USER_ACTION,
getDisplayName: () => 'Call phone number',
isCompatible: async ({ user }) => user.phone !== undefined,
@@ -126,6 +132,8 @@ export const createPhoneUserAction = (getUiActionsApi: () => Promise {
uiActionsApi.executeTriggerActions(HELLO_WORLD_TRIGGER_ID, {})}
+ onClick={() => uiActionsApi.executeTriggerActions(HELLO_WORLD_TRIGGER_ID, undefined)}
>
Say hello world!
diff --git a/examples/ui_actions_explorer/public/plugin.tsx b/examples/ui_actions_explorer/public/plugin.tsx
index 953bfd3f52692..fecada71099e8 100644
--- a/examples/ui_actions_explorer/public/plugin.tsx
+++ b/examples/ui_actions_explorer/public/plugin.tsx
@@ -35,6 +35,9 @@ import {
makePhoneCallAction,
showcasePluggability,
SHOWCASE_PLUGGABILITY_ACTION,
+ UserContext,
+ CountryContext,
+ PhoneContext,
} from './actions/actions';
interface StartDeps {
@@ -45,6 +48,14 @@ interface SetupDeps {
uiActions: UiActionsSetup;
}
+declare module '../../../src/plugins/ui_actions/public' {
+ export interface TriggerContextMapping {
+ [USER_TRIGGER]: UserContext;
+ [COUNTRY_TRIGGER]: CountryContext;
+ [PHONE_TRIGGER]: PhoneContext;
+ }
+}
+
export class UiActionsExplorerPlugin implements Plugin {
public setup(core: CoreSetup<{ uiActions: UiActionsStart }>, deps: SetupDeps) {
deps.uiActions.registerTrigger({
diff --git a/examples/ui_actions_explorer/public/trigger_context_example.tsx b/examples/ui_actions_explorer/public/trigger_context_example.tsx
index 09e1de05bb313..00d974e938138 100644
--- a/examples/ui_actions_explorer/public/trigger_context_example.tsx
+++ b/examples/ui_actions_explorer/public/trigger_context_example.tsx
@@ -47,9 +47,7 @@ const createRowData = (
{
- uiActionsApi.executeTriggerActions(COUNTRY_TRIGGER, {
- country: user.countryOfResidence,
- });
+ uiActionsApi.executeTriggerActions(COUNTRY_TRIGGER, user.countryOfResidence);
}}
>
{user.countryOfResidence}
@@ -59,10 +57,9 @@ const createRowData = (
phone: (
{
- uiActionsApi.executeTriggerActions(PHONE_TRIGGER, {
- phone: user.phone,
- });
+ uiActionsApi.executeTriggerActions(PHONE_TRIGGER, user.phone!);
}}
>
{user.phone}
diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/search_embeddable.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/search_embeddable.ts
index 2bb76386bb7ba..738a74d93449d 100644
--- a/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/search_embeddable.ts
+++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/search_embeddable.ts
@@ -20,7 +20,7 @@ import _ from 'lodash';
import * as Rx from 'rxjs';
import { Subscription } from 'rxjs';
import { i18n } from '@kbn/i18n';
-import { ExecuteTriggerActions } from 'src/plugins/ui_actions/public';
+import { UiActionsStart } from 'src/plugins/ui_actions/public';
import { RequestAdapter, Adapters } from '../../../../../../../plugins/inspector/public';
import {
esFilters,
@@ -110,7 +110,7 @@ export class SearchEmbeddable extends Embeddable
filterManager,
}: SearchEmbeddableConfig,
initialInput: SearchInput,
- private readonly executeTriggerActions: ExecuteTriggerActions,
+ private readonly executeTriggerActions: UiActionsStart['executeTriggerActions'],
parent?: Container
) {
super(
diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/search_embeddable_factory.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/search_embeddable_factory.ts
index 15b3f2d4517ac..90f1549c9f369 100644
--- a/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/search_embeddable_factory.ts
+++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/search_embeddable_factory.ts
@@ -19,7 +19,7 @@
import { auto } from 'angular';
import { i18n } from '@kbn/i18n';
-import { ExecuteTriggerActions } from 'src/plugins/ui_actions/public';
+import { UiActionsStart } from 'src/plugins/ui_actions/public';
import { getServices } from '../../kibana_services';
import {
EmbeddableFactory,
@@ -43,7 +43,7 @@ export class SearchEmbeddableFactory extends EmbeddableFactory<
public isEditable: () => boolean;
constructor(
- private readonly executeTriggerActions: ExecuteTriggerActions,
+ private readonly executeTriggerActions: UiActionsStart['executeTriggerActions'],
getInjector: () => Promise,
isEditable: () => boolean
) {
diff --git a/src/plugins/embeddable/public/bootstrap.ts b/src/plugins/embeddable/public/bootstrap.ts
index 9989345df2796..93a15aab7a0dd 100644
--- a/src/plugins/embeddable/public/bootstrap.ts
+++ b/src/plugins/embeddable/public/bootstrap.ts
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { UiActionsSetup } from 'src/plugins/ui_actions/public';
+import { UiActionsSetup } from '../../ui_actions/public';
import { Filter } from '../../data/public';
import {
applyFilterTrigger,
@@ -27,6 +27,7 @@ import {
valueClickTrigger,
EmbeddableVisTriggerContext,
IEmbeddable,
+ EmbeddableContext,
APPLY_FILTER_TRIGGER,
VALUE_CLICK_TRIGGER,
SELECT_RANGE_TRIGGER,
@@ -42,8 +43,8 @@ declare module '../../ui_actions/public' {
embeddable: IEmbeddable;
filters: Filter[];
};
- [CONTEXT_MENU_TRIGGER]: object;
- [PANEL_BADGE_TRIGGER]: object;
+ [CONTEXT_MENU_TRIGGER]: EmbeddableContext;
+ [PANEL_BADGE_TRIGGER]: EmbeddableContext;
}
}
diff --git a/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.tsx b/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.tsx
index f604cb0c274ba..e15f1faaa397c 100644
--- a/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.tsx
+++ b/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.tsx
@@ -23,7 +23,7 @@ import React from 'react';
import { EuiLoadingChart } from '@elastic/eui';
import { Subscription } from 'rxjs';
import { CoreStart } from 'src/core/public';
-import { GetActionsCompatibleWithTrigger } from 'src/plugins/ui_actions/public';
+import { UiActionsService } from 'src/plugins/ui_actions/public';
import { Start as InspectorStartContract } from 'src/plugins/inspector/public';
import { ErrorEmbeddable, IEmbeddable } from '../embeddables';
@@ -35,7 +35,7 @@ export interface EmbeddableChildPanelProps {
embeddableId: string;
className?: string;
container: IContainer;
- getActions: GetActionsCompatibleWithTrigger;
+ getActions: UiActionsService['getTriggerCompatibleActions'];
getEmbeddableFactory: GetEmbeddableFactory;
getAllEmbeddableFactories: GetEmbeddableFactories;
overlays: CoreStart['overlays'];
diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx
index 79d59317767d9..218660462b4ef 100644
--- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx
+++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx
@@ -44,7 +44,7 @@ import {
import { inspectorPluginMock } from 'src/plugins/inspector/public/mocks';
import { EuiBadge } from '@elastic/eui';
-const actionRegistry = new Map();
+const actionRegistry = new Map>();
const triggerRegistry = new Map();
const embeddableFactories = new Map();
const getEmbeddableFactory: GetEmbeddableFactory = (id: string) => embeddableFactories.get(id);
diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx
index c5f4265ac3b0d..28474544f40b5 100644
--- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx
+++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx
@@ -20,12 +20,12 @@ import { EuiContextMenuPanelDescriptor, EuiPanel, htmlIdGenerator } from '@elast
import classNames from 'classnames';
import React from 'react';
import { Subscription } from 'rxjs';
-import { buildContextMenuForActions, GetActionsCompatibleWithTrigger, Action } from '../ui_actions';
+import { buildContextMenuForActions, UiActionsService, Action } from '../ui_actions';
import { CoreStart, OverlayStart } from '../../../../../core/public';
import { toMountPoint } from '../../../../kibana_react/public';
import { Start as InspectorStartContract } from '../inspector';
-import { CONTEXT_MENU_TRIGGER, PANEL_BADGE_TRIGGER } from '../triggers';
+import { CONTEXT_MENU_TRIGGER, PANEL_BADGE_TRIGGER, EmbeddableContext } from '../triggers';
import { IEmbeddable } from '../embeddables/i_embeddable';
import { ViewMode, GetEmbeddableFactory, GetEmbeddableFactories } from '../types';
@@ -39,7 +39,7 @@ import { CustomizePanelModal } from './panel_header/panel_actions/customize_titl
interface Props {
embeddable: IEmbeddable;
- getActions: GetActionsCompatibleWithTrigger;
+ getActions: UiActionsService['getTriggerCompatibleActions'];
getEmbeddableFactory: GetEmbeddableFactory;
getAllEmbeddableFactories: GetEmbeddableFactories;
overlays: CoreStart['overlays'];
@@ -55,7 +55,7 @@ interface State {
viewMode: ViewMode;
hidePanelTitles: boolean;
closeContextMenu: boolean;
- badges: Action[];
+ badges: Array>;
}
export class EmbeddablePanel extends React.Component {
@@ -87,7 +87,7 @@ export class EmbeddablePanel extends React.Component {
}
private async refreshBadges() {
- let badges: Action[] = await this.props.getActions(PANEL_BADGE_TRIGGER, {
+ let badges = await this.props.getActions(PANEL_BADGE_TRIGGER, {
embeddable: this.props.embeddable,
});
if (!this.mounted) return;
@@ -231,7 +231,7 @@ export class EmbeddablePanel extends React.Component {
// These actions are exposed on the context menu for every embeddable, they bypass the trigger
// registry.
- const extraActions: Array> = [
+ const extraActions: Array> = [
new CustomizePanelTitleAction(createGetUserData(this.props.overlays)),
new AddPanelAction(
this.props.getEmbeddableFactory,
@@ -245,11 +245,13 @@ export class EmbeddablePanel extends React.Component {
new EditPanelAction(this.props.getEmbeddableFactory),
];
- const sorted = actions.concat(extraActions).sort((a: Action, b: Action) => {
- const bOrder = b.order || 0;
- const aOrder = a.order || 0;
- return bOrder - aOrder;
- });
+ const sorted = actions
+ .concat(extraActions)
+ .sort((a: Action, b: Action) => {
+ const bOrder = b.order || 0;
+ const aOrder = a.order || 0;
+ return bOrder - aOrder;
+ });
return await buildContextMenuForActions({
actions: sorted,
diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx
index cc0733a08dd78..99516a1d21d6f 100644
--- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx
+++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx
@@ -29,6 +29,7 @@ import React from 'react';
import { Action } from 'src/plugins/ui_actions/public';
import { PanelOptionsMenu } from './panel_options_menu';
import { IEmbeddable } from '../../embeddables';
+import { EmbeddableContext } from '../../triggers';
export interface PanelHeaderProps {
title?: string;
@@ -36,12 +37,12 @@ export interface PanelHeaderProps {
hidePanelTitles: boolean;
getActionContextMenuPanel: () => Promise;
closeContextMenu: boolean;
- badges: Action[];
+ badges: Array>;
embeddable: IEmbeddable;
headerId?: string;
}
-function renderBadges(badges: Action[], embeddable: IEmbeddable) {
+function renderBadges(badges: Array>, embeddable: IEmbeddable) {
return badges.map(badge => (
({
+ return createAction({
type: EDIT_MODE_ACTION,
getDisplayName: () => 'I only show up in edit mode',
isCompatible: async context => context.embeddable.getInput().viewMode === ViewMode.EDIT,
diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx
index a8c760f7b9497..01228c778754b 100644
--- a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx
+++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx
@@ -22,12 +22,19 @@ import { EuiCard, EuiFlexItem, EuiFlexGroup, EuiFormRow } from '@elastic/eui';
import { Subscription } from 'rxjs';
import { EuiButton } from '@elastic/eui';
import * as Rx from 'rxjs';
-import { ExecuteTriggerActions } from 'src/plugins/ui_actions/public';
+import { UiActionsStart } from '../../../../../../ui_actions/public';
import { ContactCardEmbeddable, CONTACT_USER_TRIGGER } from './contact_card_embeddable';
+import { EmbeddableContext } from '../../../triggers';
+
+declare module '../../../../../../ui_actions/public' {
+ export interface TriggerContextMapping {
+ [CONTACT_USER_TRIGGER]: EmbeddableContext;
+ }
+}
interface Props {
embeddable: ContactCardEmbeddable;
- execTrigger: ExecuteTriggerActions;
+ execTrigger: UiActionsStart['executeTriggerActions'];
}
interface State {
@@ -72,7 +79,6 @@ export class ContactCardEmbeddableComponent extends React.Component {
this.props.execTrigger(CONTACT_USER_TRIGGER, {
embeddable: this.props.embeddable,
- triggerContext: {},
});
};
diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable.tsx
index 48f9cd2ce516d..078e21df0f0ce 100644
--- a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable.tsx
+++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable.tsx
@@ -19,7 +19,7 @@
import React from 'react';
import ReactDom from 'react-dom';
import { Subscription } from 'rxjs';
-import { ExecuteTriggerActions } from 'src/plugins/ui_actions/public';
+import { UiActionsStart } from 'src/plugins/ui_actions/public';
import { Container } from '../../../containers';
import { EmbeddableOutput, Embeddable, EmbeddableInput } from '../../../embeddables';
import { CONTACT_CARD_EMBEDDABLE } from './contact_card_embeddable_factory';
@@ -37,7 +37,7 @@ export interface ContactCardEmbeddableOutput extends EmbeddableOutput {
}
export interface ContactCardEmbeddableOptions {
- execAction: ExecuteTriggerActions;
+ execAction: UiActionsStart['executeTriggerActions'];
}
function getFullName(input: ContactCardEmbeddableInput) {
diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx
index 838c8d7de8f12..7a9ba4fbbf6d6 100644
--- a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx
+++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx
@@ -19,7 +19,7 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
-import { ExecuteTriggerActions } from 'src/plugins/ui_actions/public';
+import { UiActionsStart } from 'src/plugins/ui_actions/public';
import { CoreStart } from 'src/core/public';
import { toMountPoint } from '../../../../../../kibana_react/public';
@@ -36,7 +36,7 @@ export class ContactCardEmbeddableFactory extends EmbeddableFactory,
- private readonly execTrigger: ExecuteTriggerActions,
+ private readonly execTrigger: UiActionsStart['executeTriggerActions'],
private readonly overlays: CoreStart['overlays']
) {
super(options);
diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/slow_contact_card_embeddable_factory.ts b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/slow_contact_card_embeddable_factory.ts
index d16cd6dcd2187..b90e16c13fc62 100644
--- a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/slow_contact_card_embeddable_factory.ts
+++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/slow_contact_card_embeddable_factory.ts
@@ -17,13 +17,13 @@
* under the License.
*/
-import { ExecuteTriggerActions } from 'src/plugins/ui_actions/public';
+import { UiActionsStart } from 'src/plugins/ui_actions/public';
import { Container, EmbeddableFactory } from '../../..';
import { ContactCardEmbeddable, ContactCardEmbeddableInput } from './contact_card_embeddable';
import { CONTACT_CARD_EMBEDDABLE } from './contact_card_embeddable_factory';
interface SlowContactCardEmbeddableFactoryOptions {
- execAction: ExecuteTriggerActions;
+ execAction: UiActionsStart['executeTriggerActions'];
loadTickCount?: number;
}
diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx
index 7eca9f64bf937..c5ba054bebb7a 100644
--- a/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx
+++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx
@@ -20,7 +20,7 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { I18nProvider } from '@kbn/i18n/react';
import { CoreStart } from 'src/core/public';
-import { GetActionsCompatibleWithTrigger } from 'src/plugins/ui_actions/public';
+import { UiActionsService } from 'src/plugins/ui_actions/public';
import { Start as InspectorStartContract } from 'src/plugins/inspector/public';
import { Container, ViewMode, ContainerInput } from '../..';
import { HelloWorldContainerComponent } from './hello_world_container_component';
@@ -45,7 +45,7 @@ interface HelloWorldContainerInput extends ContainerInput {
}
interface HelloWorldContainerOptions {
- getActions: GetActionsCompatibleWithTrigger;
+ getActions: UiActionsService['getTriggerCompatibleActions'];
getEmbeddableFactory: GetEmbeddableFactory;
getAllEmbeddableFactories: GetEmbeddableFactories;
overlays: CoreStart['overlays'];
diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container_component.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container_component.tsx
index 413a0914bff65..e9acfd4539768 100644
--- a/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container_component.tsx
+++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container_component.tsx
@@ -21,14 +21,14 @@ import { Subscription } from 'rxjs';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { CoreStart } from 'src/core/public';
-import { GetActionsCompatibleWithTrigger } from 'src/plugins/ui_actions/public';
+import { UiActionsService } from 'src/plugins/ui_actions/public';
import { Start as InspectorStartContract } from 'src/plugins/inspector/public';
import { IContainer, PanelState, EmbeddableChildPanel } from '../..';
import { GetEmbeddableFactory, GetEmbeddableFactories } from '../../types';
interface Props {
container: IContainer;
- getActions: GetActionsCompatibleWithTrigger;
+ getActions: UiActionsService['getTriggerCompatibleActions'];
getEmbeddableFactory: GetEmbeddableFactory;
getAllEmbeddableFactories: GetEmbeddableFactories;
overlays: CoreStart['overlays'];
diff --git a/src/plugins/embeddable/public/lib/triggers/triggers.ts b/src/plugins/embeddable/public/lib/triggers/triggers.ts
index 491d9e730eb75..a348e1ed79d8d 100644
--- a/src/plugins/embeddable/public/lib/triggers/triggers.ts
+++ b/src/plugins/embeddable/public/lib/triggers/triggers.ts
@@ -20,6 +20,10 @@
import { Trigger } from '../../../../ui_actions/public';
import { IEmbeddable } from '..';
+export interface EmbeddableContext {
+ embeddable: IEmbeddable;
+}
+
export interface EmbeddableVisTriggerContext {
embeddable: IEmbeddable;
timeFieldName: string;
diff --git a/src/plugins/ui_actions/public/actions/action.ts b/src/plugins/ui_actions/public/actions/action.ts
index 22530f003f2cd..854e2c8c1cb09 100644
--- a/src/plugins/ui_actions/public/actions/action.ts
+++ b/src/plugins/ui_actions/public/actions/action.ts
@@ -19,7 +19,7 @@
import { UiComponent } from 'src/plugins/kibana_utils/common';
-export interface Action {
+export interface Action {
/**
* Determined the order when there is more than one action matched to a trigger.
* Higher numbers are displayed first.
@@ -33,33 +33,33 @@ export interface Action {
/**
* Optional EUI icon type that can be displayed along with the title.
*/
- getIconType(context: ActionContext): string | undefined;
+ getIconType(context: Context): string | undefined;
/**
* Returns a title to be displayed to the user.
* @param context
*/
- getDisplayName(context: ActionContext): string;
+ getDisplayName(context: Context): string;
/**
* `UiComponent` to render when displaying this action as a context menu item.
* If not provided, `getDisplayName` will be used instead.
*/
- MenuItem?: UiComponent<{ context: ActionContext }>;
+ MenuItem?: UiComponent<{ context: Context }>;
/**
* Returns a promise that resolves to true if this action is compatible given the context,
* otherwise resolves to false.
*/
- isCompatible(context: ActionContext): Promise;
+ isCompatible(context: Context): Promise;
/**
* If this returns something truthy, this is used in addition to the `execute` method when clicked.
*/
- getHref?(context: ActionContext): string | undefined;
+ getHref?(context: Context): string | undefined;
/**
* Executes the action.
*/
- execute(context: ActionContext): Promise;
+ execute(context: Context): Promise;
}
diff --git a/src/plugins/ui_actions/public/actions/create_action.ts b/src/plugins/ui_actions/public/actions/create_action.ts
index 0cec076745334..4077cf1081021 100644
--- a/src/plugins/ui_actions/public/actions/create_action.ts
+++ b/src/plugins/ui_actions/public/actions/create_action.ts
@@ -19,11 +19,9 @@
import { Action } from './action';
-export function createAction(
- action: { type: string; execute: Action['execute'] } & Partial<
- Action
- >
-): Action {
+export function createAction(
+ action: { type: string; execute: Action['execute'] } & Partial>
+): Action {
return {
getIconType: () => undefined,
order: 0,
diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts
index 1ce48d5460b2e..eb69aefdbb50e 100644
--- a/src/plugins/ui_actions/public/index.ts
+++ b/src/plugins/ui_actions/public/index.ts
@@ -19,7 +19,6 @@
import { PluginInitializerContext } from '../../../core/public';
import { UiActionsPlugin } from './plugin';
-import { UiActionsService } from './service';
export function plugin(initializerContext: PluginInitializerContext) {
return new UiActionsPlugin(initializerContext);
@@ -30,20 +29,4 @@ export { UiActionsServiceParams, UiActionsService } from './service';
export { Action, createAction, IncompatibleActionError } from './actions';
export { buildContextMenuForActions } from './context_menu';
export { Trigger, TriggerContext } from './triggers';
-export { TriggerContextMapping } from './types';
-
-/**
- * @deprecated
- *
- * Use `UiActionsStart['getTriggerCompatibleActions']` or
- * `UiActionsService['getTriggerCompatibleActions']` instead.
- */
-export type GetActionsCompatibleWithTrigger = UiActionsService['getTriggerCompatibleActions'];
-
-/**
- * @deprecated
- *
- * Use `UiActionsStart['executeTriggerActions']` or
- * `UiActionsService['executeTriggerActions']` instead.
- */
-export type ExecuteTriggerActions = UiActionsService['executeTriggerActions'];
+export { TriggerContextMapping, TriggerId } from './types';
diff --git a/src/plugins/ui_actions/public/mocks.ts b/src/plugins/ui_actions/public/mocks.ts
index d2ba901f1040d..948450495384a 100644
--- a/src/plugins/ui_actions/public/mocks.ts
+++ b/src/plugins/ui_actions/public/mocks.ts
@@ -21,6 +21,7 @@ import { CoreSetup, CoreStart } from 'src/core/public';
import { UiActionsSetup, UiActionsStart } from '.';
import { plugin as pluginInitializer } from '.';
import { coreMock } from '../../../core/public/mocks';
+import { TriggerId } from './types';
export type Setup = jest.Mocked;
export type Start = jest.Mocked;
@@ -43,7 +44,7 @@ const createStartContract = (): Start => {
detachAction: jest.fn(),
executeTriggerActions: jest.fn(),
getTrigger: jest.fn(),
- getTriggerActions: jest.fn((id: string) => []),
+ getTriggerActions: jest.fn((id: TriggerId) => []),
getTriggerCompatibleActions: jest.fn(),
clear: jest.fn(),
fork: jest.fn(),
diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts
index 8963ba4ddb005..c52b975358610 100644
--- a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts
+++ b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts
@@ -20,9 +20,16 @@
import { UiActionsService } from './ui_actions_service';
import { Action } from '../actions';
import { createRestrictedAction, createHelloWorldAction } from '../tests/test_samples';
-import { ActionRegistry, TriggerRegistry } from '../types';
+import { ActionRegistry, TriggerRegistry, TriggerId } from '../types';
import { Trigger } from '../triggers';
+// I tried redeclaring the module in here to extend the `TriggerContextMapping` but
+// that seems to overwrite all other plugins extending it, I suspect because it's inside
+// the main plugin.
+const FOO_TRIGGER: TriggerId = 'FOO_TRIGGER' as TriggerId;
+const BAR_TRIGGER: TriggerId = 'BAR_TRIGGER' as TriggerId;
+const MY_TRIGGER: TriggerId = 'MY_TRIGGER' as TriggerId;
+
const testAction1: Action = {
id: 'action1',
order: 1,
@@ -52,7 +59,7 @@ describe('UiActionsService', () => {
test('can register a trigger', () => {
const service = new UiActionsService();
service.registerTrigger({
- id: 'test',
+ id: BAR_TRIGGER,
});
});
});
@@ -62,15 +69,15 @@ describe('UiActionsService', () => {
const service = new UiActionsService();
service.registerTrigger({
description: 'foo',
- id: 'bar',
+ id: BAR_TRIGGER,
title: 'baz',
});
- const trigger = service.getTrigger('bar');
+ const trigger = service.getTrigger(BAR_TRIGGER);
expect(trigger).toMatchObject({
description: 'foo',
- id: 'bar',
+ id: BAR_TRIGGER,
title: 'baz',
});
});
@@ -78,8 +85,8 @@ describe('UiActionsService', () => {
test('throws if trigger does not exist', () => {
const service = new UiActionsService();
- expect(() => service.getTrigger('foo')).toThrowError(
- 'Trigger [triggerId = foo] does not exist.'
+ expect(() => service.getTrigger(FOO_TRIGGER)).toThrowError(
+ 'Trigger [triggerId = FOO_TRIGGER] does not exist.'
);
});
});
@@ -125,22 +132,22 @@ describe('UiActionsService', () => {
service.registerAction(action2);
service.registerTrigger({
description: 'foo',
- id: 'trigger',
+ id: FOO_TRIGGER,
title: 'baz',
});
- const list0 = service.getTriggerActions('trigger');
+ const list0 = service.getTriggerActions(FOO_TRIGGER);
expect(list0).toHaveLength(0);
- service.attachAction('trigger', 'action1');
- const list1 = service.getTriggerActions('trigger');
+ service.attachAction(FOO_TRIGGER, 'action1');
+ const list1 = service.getTriggerActions(FOO_TRIGGER);
expect(list1).toHaveLength(1);
expect(list1).toEqual([action1]);
- service.attachAction('trigger', 'action2');
- const list2 = service.getTriggerActions('trigger');
+ service.attachAction(FOO_TRIGGER, 'action2');
+ const list2 = service.getTriggerActions(FOO_TRIGGER);
expect(list2).toHaveLength(2);
expect(!!list2.find(({ id }: any) => id === 'action1')).toBe(true);
@@ -168,13 +175,15 @@ describe('UiActionsService', () => {
service.registerAction(helloWorldAction);
const testTrigger: Trigger = {
- id: 'MY-TRIGGER',
+ id: MY_TRIGGER,
title: 'My trigger',
};
service.registerTrigger(testTrigger);
- service.attachAction('MY-TRIGGER', helloWorldAction.id);
+ service.attachAction(MY_TRIGGER, helloWorldAction.id);
- const compatibleActions = await service.getTriggerCompatibleActions('MY-TRIGGER', {});
+ const compatibleActions = await service.getTriggerCompatibleActions(MY_TRIGGER, {
+ hi: 'there',
+ });
expect(compatibleActions.length).toBe(1);
expect(compatibleActions[0].id).toBe(helloWorldAction.id);
@@ -189,7 +198,7 @@ describe('UiActionsService', () => {
service.registerAction(restrictedAction);
const testTrigger: Trigger = {
- id: 'MY-TRIGGER',
+ id: MY_TRIGGER,
title: 'My trigger',
};
@@ -212,15 +221,16 @@ describe('UiActionsService', () => {
test(`throws an error with an invalid trigger ID`, async () => {
const service = new UiActionsService();
- await expect(service.getTriggerCompatibleActions('I do not exist', {})).rejects.toMatchObject(
- new Error('Trigger [triggerId = I do not exist] does not exist.')
- );
+ // Without the cast "as TriggerId" typescript will happily throw an error!
+ await expect(
+ service.getTriggerCompatibleActions('I do not exist' as TriggerId, {})
+ ).rejects.toMatchObject(new Error('Trigger [triggerId = I do not exist] does not exist.'));
});
test('returns empty list if trigger not attached to any action', async () => {
const service = new UiActionsService();
const testTrigger: Trigger = {
- id: '123',
+ id: '123' as TriggerId,
title: '123',
};
service.registerTrigger(testTrigger);
@@ -243,15 +253,15 @@ describe('UiActionsService', () => {
test('triggers registered in original service are available in original an forked services', () => {
const service1 = new UiActionsService();
service1.registerTrigger({
- id: 'foo',
+ id: FOO_TRIGGER,
});
const service2 = service1.fork();
- const trigger1 = service1.getTrigger('foo');
- const trigger2 = service2.getTrigger('foo');
+ const trigger1 = service1.getTrigger(FOO_TRIGGER);
+ const trigger2 = service2.getTrigger(FOO_TRIGGER);
- expect(trigger1.id).toBe('foo');
- expect(trigger2.id).toBe('foo');
+ expect(trigger1.id).toBe(FOO_TRIGGER);
+ expect(trigger2.id).toBe(FOO_TRIGGER);
});
test('triggers registered in forked service are not available in original service', () => {
@@ -259,30 +269,30 @@ describe('UiActionsService', () => {
const service2 = service1.fork();
service2.registerTrigger({
- id: 'foo',
+ id: FOO_TRIGGER,
});
- expect(() => service1.getTrigger('foo')).toThrowErrorMatchingInlineSnapshot(
- `"Trigger [triggerId = foo] does not exist."`
+ expect(() => service1.getTrigger(FOO_TRIGGER)).toThrowErrorMatchingInlineSnapshot(
+ `"Trigger [triggerId = FOO_TRIGGER] does not exist."`
);
- const trigger2 = service2.getTrigger('foo');
- expect(trigger2.id).toBe('foo');
+ const trigger2 = service2.getTrigger(FOO_TRIGGER);
+ expect(trigger2.id).toBe(FOO_TRIGGER);
});
test('forked service preserves trigger-to-actions mapping', () => {
const service1 = new UiActionsService();
service1.registerTrigger({
- id: 'foo',
+ id: FOO_TRIGGER,
});
service1.registerAction(testAction1);
- service1.attachAction('foo', testAction1.id);
+ service1.attachAction(FOO_TRIGGER, testAction1.id);
const service2 = service1.fork();
- const actions1 = service1.getTriggerActions('foo');
- const actions2 = service2.getTriggerActions('foo');
+ const actions1 = service1.getTriggerActions(FOO_TRIGGER);
+ const actions2 = service2.getTriggerActions(FOO_TRIGGER);
expect(actions1).toHaveLength(1);
expect(actions2).toHaveLength(1);
@@ -294,42 +304,42 @@ describe('UiActionsService', () => {
const service1 = new UiActionsService();
service1.registerTrigger({
- id: 'foo',
+ id: FOO_TRIGGER,
});
service1.registerAction(testAction1);
service1.registerAction(testAction2);
- service1.attachAction('foo', testAction1.id);
+ service1.attachAction(FOO_TRIGGER, testAction1.id);
const service2 = service1.fork();
- expect(service1.getTriggerActions('foo')).toHaveLength(1);
- expect(service2.getTriggerActions('foo')).toHaveLength(1);
+ expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
+ expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
- service2.attachAction('foo', testAction2.id);
+ service2.attachAction(FOO_TRIGGER, testAction2.id);
- expect(service1.getTriggerActions('foo')).toHaveLength(1);
- expect(service2.getTriggerActions('foo')).toHaveLength(2);
+ expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
+ expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(2);
});
test('new attachments in original service do not appear in fork', () => {
const service1 = new UiActionsService();
service1.registerTrigger({
- id: 'foo',
+ id: FOO_TRIGGER,
});
service1.registerAction(testAction1);
service1.registerAction(testAction2);
- service1.attachAction('foo', testAction1.id);
+ service1.attachAction(FOO_TRIGGER, testAction1.id);
const service2 = service1.fork();
- expect(service1.getTriggerActions('foo')).toHaveLength(1);
- expect(service2.getTriggerActions('foo')).toHaveLength(1);
+ expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
+ expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
- service1.attachAction('foo', testAction2.id);
+ service1.attachAction(FOO_TRIGGER, testAction2.id);
- expect(service1.getTriggerActions('foo')).toHaveLength(2);
- expect(service2.getTriggerActions('foo')).toHaveLength(1);
+ expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(2);
+ expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
});
});
@@ -342,14 +352,14 @@ describe('UiActionsService', () => {
service.registerTrigger({
description: 'foo',
- id: 'bar',
+ id: BAR_TRIGGER,
title: 'baz',
});
- const triggerContract = service.getTrigger('bar');
+ const triggerContract = service.getTrigger(BAR_TRIGGER);
expect(triggerContract).toMatchObject({
description: 'foo',
- id: 'bar',
+ id: BAR_TRIGGER,
title: 'baz',
});
});
@@ -373,7 +383,7 @@ describe('UiActionsService', () => {
const service = new UiActionsService();
const trigger: Trigger = {
- id: 'MY-TRIGGER',
+ id: MY_TRIGGER,
};
const action = {
id: HELLO_WORLD_ACTION_ID,
@@ -382,7 +392,7 @@ describe('UiActionsService', () => {
service.registerTrigger(trigger);
service.registerAction(action);
- service.attachAction('MY-TRIGGER', HELLO_WORLD_ACTION_ID);
+ service.attachAction(MY_TRIGGER, HELLO_WORLD_ACTION_ID);
const actions = service.getTriggerActions(trigger.id);
@@ -394,7 +404,7 @@ describe('UiActionsService', () => {
const service = new UiActionsService();
const trigger: Trigger = {
- id: 'MY-TRIGGER',
+ id: MY_TRIGGER,
};
const action = {
id: HELLO_WORLD_ACTION_ID,
@@ -419,7 +429,9 @@ describe('UiActionsService', () => {
} as any;
service.registerAction(action);
- expect(() => service.detachAction('i do not exist', HELLO_WORLD_ACTION_ID)).toThrowError(
+ expect(() =>
+ service.detachAction('i do not exist' as TriggerId, HELLO_WORLD_ACTION_ID)
+ ).toThrowError(
'No trigger [triggerId = i do not exist] exists, for detaching action [actionId = HELLO_WORLD_ACTION_ID].'
);
});
@@ -433,7 +445,9 @@ describe('UiActionsService', () => {
} as any;
service.registerAction(action);
- expect(() => service.attachAction('i do not exist', HELLO_WORLD_ACTION_ID)).toThrowError(
+ expect(() =>
+ service.attachAction('i do not exist' as TriggerId, HELLO_WORLD_ACTION_ID)
+ ).toThrowError(
'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = HELLO_WORLD_ACTION_ID].'
);
});
diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.ts b/src/plugins/ui_actions/public/service/ui_actions_service.ts
index ae409830bbb6e..66f038f05a4ac 100644
--- a/src/plugins/ui_actions/public/service/ui_actions_service.ts
+++ b/src/plugins/ui_actions/public/service/ui_actions_service.ts
@@ -17,7 +17,13 @@
* under the License.
*/
-import { TriggerRegistry, ActionRegistry, TriggerToActionsRegistry, TriggerId } from '../types';
+import {
+ TriggerRegistry,
+ ActionRegistry,
+ TriggerToActionsRegistry,
+ TriggerId,
+ TriggerContextMapping,
+} from '../types';
import { Action } from '../actions';
import { Trigger, TriggerContext } from '../triggers/trigger';
import { TriggerInternal } from '../triggers/trigger_internal';
@@ -60,7 +66,7 @@ export class UiActionsService {
};
public readonly getTrigger = (triggerId: T): TriggerContract => {
- const trigger = this.triggers.get(triggerId as string);
+ const trigger = this.triggers.get(triggerId);
if (!trigger) {
throw new Error(`Trigger [triggerId = ${triggerId}] does not exist.`);
@@ -69,7 +75,7 @@ export class UiActionsService {
return trigger.contract;
};
- public readonly registerAction = (action: Action) => {
+ public readonly registerAction = (action: Action) => {
if (this.actions.has(action.id)) {
throw new Error(`Action [action.id = ${action.id}] already registered.`);
}
@@ -77,7 +83,10 @@ export class UiActionsService {
this.actions.set(action.id, action);
};
- public readonly attachAction = (triggerId: string, actionId: string): void => {
+ // TODO: make this
+ // (triggerId: T, action: Action): \
+ // to get type checks here!
+ public readonly attachAction = (triggerId: T, actionId: string): void => {
const trigger = this.triggers.get(triggerId);
if (!trigger) {
@@ -93,7 +102,7 @@ export class UiActionsService {
}
};
- public readonly detachAction = (triggerId: string, actionId: string) => {
+ public readonly detachAction = (triggerId: TriggerId, actionId: string) => {
const trigger = this.triggers.get(triggerId);
if (!trigger) {
@@ -110,23 +119,30 @@ export class UiActionsService {
);
};
- public readonly getTriggerActions = (triggerId: string) => {
+ public readonly getTriggerActions = (
+ triggerId: T
+ ): Array> => {
// This line checks if trigger exists, otherwise throws.
this.getTrigger!(triggerId);
const actionIds = this.triggerToActions.get(triggerId);
- const actions = actionIds!
- .map(actionId => this.actions.get(actionId))
- .filter(Boolean) as Action[];
- return actions;
+ const actions = actionIds!.map(actionId => this.actions.get(actionId)).filter(Boolean) as Array<
+ Action
+ >;
+
+ return actions as Array>>;
};
- public readonly getTriggerCompatibleActions = async (triggerId: string, context: C) => {
+ public readonly getTriggerCompatibleActions = async (
+ triggerId: T,
+ context: TriggerContextMapping[T]
+ ): Promise>> => {
const actions = this.getTriggerActions!(triggerId);
const isCompatibles = await Promise.all(actions.map(action => action.isCompatible(context)));
- return actions.reduce(
- (acc, action, i) => (isCompatibles[i] ? [...acc, action] : acc),
+ return actions.reduce(
+ (acc: Array>, action, i) =>
+ isCompatibles[i] ? [...acc, action] : acc,
[]
);
};
diff --git a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts
index f8c196a623499..450bfbfc6c959 100644
--- a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts
+++ b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts
@@ -21,6 +21,7 @@ import { Action, createAction } from '../actions';
import { openContextMenu } from '../context_menu';
import { uiActionsPluginMock } from '../mocks';
import { Trigger } from '../triggers';
+import { TriggerId } from '../types';
jest.mock('../context_menu');
@@ -55,7 +56,7 @@ beforeEach(reset);
test('executes a single action mapped to a trigger', async () => {
const { setup, doStart } = uiActions;
const trigger: Trigger = {
- id: 'MY-TRIGGER',
+ id: 'MY-TRIGGER' as TriggerId,
title: 'My trigger',
};
const action = createTestAction('test1', () => true);
@@ -66,7 +67,7 @@ test('executes a single action mapped to a trigger', async () => {
const context = {};
const start = doStart();
- await start.executeTriggerActions('MY-TRIGGER', context);
+ await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context);
expect(executeFn).toBeCalledTimes(1);
expect(executeFn).toBeCalledWith(context);
@@ -75,7 +76,7 @@ test('executes a single action mapped to a trigger', async () => {
test('throws an error if there are no compatible actions to execute', async () => {
const { setup, doStart } = uiActions;
const trigger: Trigger = {
- id: 'MY-TRIGGER',
+ id: 'MY-TRIGGER' as TriggerId,
title: 'My trigger',
};
@@ -84,7 +85,9 @@ test('throws an error if there are no compatible actions to execute', async () =
const context = {};
const start = doStart();
- await expect(start.executeTriggerActions('MY-TRIGGER', context)).rejects.toMatchObject(
+ await expect(
+ start.executeTriggerActions('MY-TRIGGER' as TriggerId, context)
+ ).rejects.toMatchObject(
new Error('No compatible actions found to execute for trigger [triggerId = MY-TRIGGER].')
);
});
@@ -92,7 +95,7 @@ test('throws an error if there are no compatible actions to execute', async () =
test('does not execute an incompatible action', async () => {
const { setup, doStart } = uiActions;
const trigger: Trigger = {
- id: 'MY-TRIGGER',
+ id: 'MY-TRIGGER' as TriggerId,
title: 'My trigger',
};
const action = createTestAction<{ name: string }>('test1', ({ name }) => name === 'executeme');
@@ -105,7 +108,7 @@ test('does not execute an incompatible action', async () => {
const context = {
name: 'executeme',
};
- await start.executeTriggerActions('MY-TRIGGER', context);
+ await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context);
expect(executeFn).toBeCalledTimes(1);
});
@@ -113,7 +116,7 @@ test('does not execute an incompatible action', async () => {
test('shows a context menu when more than one action is mapped to a trigger', async () => {
const { setup, doStart } = uiActions;
const trigger: Trigger = {
- id: 'MY-TRIGGER',
+ id: 'MY-TRIGGER' as TriggerId,
title: 'My trigger',
};
const action1 = createTestAction('test1', () => true);
@@ -129,7 +132,7 @@ test('shows a context menu when more than one action is mapped to a trigger', as
const start = doStart();
const context = {};
- await start.executeTriggerActions('MY-TRIGGER', context);
+ await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context);
expect(executeFn).toBeCalledTimes(0);
expect(openContextMenu).toHaveBeenCalledTimes(1);
@@ -138,7 +141,7 @@ test('shows a context menu when more than one action is mapped to a trigger', as
test('passes whole action context to isCompatible()', async () => {
const { setup, doStart } = uiActions;
const trigger = {
- id: 'MY-TRIGGER',
+ id: 'MY-TRIGGER' as TriggerId,
title: 'My trigger',
};
const action = createTestAction<{ foo: string }>('test', ({ foo }) => {
@@ -153,5 +156,5 @@ test('passes whole action context to isCompatible()', async () => {
const start = doStart();
const context = { foo: 'bar' };
- await start.executeTriggerActions('MY-TRIGGER', context);
+ await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context);
});
diff --git a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts
index e91acd4c7151b..ae335de4b3deb 100644
--- a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts
+++ b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts
@@ -19,6 +19,7 @@
import { Action } from '../actions';
import { uiActionsPluginMock } from '../mocks';
+import { TriggerId } from '../types';
const action1: Action = {
id: 'action1',
@@ -37,23 +38,23 @@ test('returns actions set on trigger', () => {
setup.registerAction(action2);
setup.registerTrigger({
description: 'foo',
- id: 'trigger',
+ id: 'trigger' as TriggerId,
title: 'baz',
});
const start = doStart();
- const list0 = start.getTriggerActions('trigger');
+ const list0 = start.getTriggerActions('trigger' as TriggerId);
expect(list0).toHaveLength(0);
- setup.attachAction('trigger', 'action1');
- const list1 = start.getTriggerActions('trigger');
+ setup.attachAction('trigger' as TriggerId, 'action1');
+ const list1 = start.getTriggerActions('trigger' as TriggerId);
expect(list1).toHaveLength(1);
expect(list1).toEqual([action1]);
- setup.attachAction('trigger', 'action2');
- const list2 = start.getTriggerActions('trigger');
+ setup.attachAction('trigger' as TriggerId, 'action2');
+ const list2 = start.getTriggerActions('trigger' as TriggerId);
expect(list2).toHaveLength(2);
expect(!!list2.find(({ id }: any) => id === 'action1')).toBe(true);
diff --git a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts
index a966003973aba..dfb55e42b9443 100644
--- a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts
+++ b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts
@@ -22,6 +22,7 @@ import { uiActionsPluginMock } from '../mocks';
import { createRestrictedAction, createHelloWorldAction } from '../tests/test_samples';
import { Action } from '../actions';
import { Trigger } from '../triggers';
+import { TriggerId } from '../types';
let action: Action<{ name: string }>;
let uiActions: ReturnType;
@@ -31,10 +32,10 @@ beforeEach(() => {
uiActions.setup.registerAction(action);
uiActions.setup.registerTrigger({
- id: 'trigger',
+ id: 'trigger' as TriggerId,
title: 'trigger',
});
- uiActions.setup.attachAction('trigger', action.id);
+ uiActions.setup.attachAction('trigger' as TriggerId, action.id);
});
test('can register action', async () => {
@@ -51,14 +52,14 @@ test('getTriggerCompatibleActions returns attached actions', async () => {
setup.registerAction(helloWorldAction);
const testTrigger: Trigger = {
- id: 'MY-TRIGGER',
+ id: 'MY-TRIGGER' as TriggerId,
title: 'My trigger',
};
setup.registerTrigger(testTrigger);
- setup.attachAction('MY-TRIGGER', helloWorldAction.id);
+ setup.attachAction('MY-TRIGGER' as TriggerId, helloWorldAction.id);
const start = doStart();
- const actions = await start.getTriggerCompatibleActions('MY-TRIGGER', {});
+ const actions = await start.getTriggerCompatibleActions('MY-TRIGGER' as TriggerId, {});
expect(actions.length).toBe(1);
expect(actions[0].id).toBe(helloWorldAction.id);
@@ -73,7 +74,7 @@ test('filters out actions not applicable based on the context', async () => {
setup.registerAction(restrictedAction);
const testTrigger: Trigger = {
- id: 'MY-TRIGGER',
+ id: 'MY-TRIGGER' as TriggerId,
title: 'My trigger',
};
@@ -94,15 +95,15 @@ test(`throws an error with an invalid trigger ID`, async () => {
const { doStart } = uiActions;
const start = doStart();
- await expect(start.getTriggerCompatibleActions('I do not exist', {})).rejects.toMatchObject(
- new Error('Trigger [triggerId = I do not exist] does not exist.')
- );
+ await expect(
+ start.getTriggerCompatibleActions('I do not exist' as TriggerId, {})
+ ).rejects.toMatchObject(new Error('Trigger [triggerId = I do not exist] does not exist.'));
});
test(`with a trigger mapping that maps to an non-existing action returns empty list`, async () => {
const { setup, doStart } = uiActions;
const testTrigger: Trigger = {
- id: '123',
+ id: '123' as TriggerId,
title: '123',
};
setup.registerTrigger(testTrigger);
diff --git a/src/plugins/ui_actions/public/triggers/trigger_contract.ts b/src/plugins/ui_actions/public/triggers/trigger_contract.ts
index 853b83dccabcc..ba1c5a693f937 100644
--- a/src/plugins/ui_actions/public/triggers/trigger_contract.ts
+++ b/src/plugins/ui_actions/public/triggers/trigger_contract.ts
@@ -17,9 +17,8 @@
* under the License.
*/
-import { TriggerContext } from './trigger';
import { TriggerInternal } from './trigger_internal';
-import { TriggerId } from '../types';
+import { TriggerId, TriggerContextMapping } from '../types';
/**
* This is a public representation of a trigger that is provided to other plugins.
@@ -50,7 +49,7 @@ export class TriggerContract {
/**
* Use this method to execute action attached to this trigger.
*/
- public readonly exec = async (context: TriggerContext) => {
+ public readonly exec = async (context: TriggerContextMapping[T]) => {
await this.internal.execute(context);
};
}
diff --git a/src/plugins/ui_actions/public/triggers/trigger_internal.ts b/src/plugins/ui_actions/public/triggers/trigger_internal.ts
index efcdc72ecad57..5b670df354f78 100644
--- a/src/plugins/ui_actions/public/triggers/trigger_internal.ts
+++ b/src/plugins/ui_actions/public/triggers/trigger_internal.ts
@@ -17,12 +17,12 @@
* under the License.
*/
-import { TriggerContext, Trigger } from './trigger';
+import { Trigger } from './trigger';
import { TriggerContract } from './trigger_contract';
import { UiActionsService } from '../service';
import { Action } from '../actions';
import { buildContextMenuForActions, openContextMenu } from '../context_menu';
-import { TriggerId } from '../types';
+import { TriggerId, TriggerContextMapping } from '../types';
/**
* Internal representation of a trigger kept for consumption only internally
@@ -33,7 +33,7 @@ export class TriggerInternal {
constructor(public readonly service: UiActionsService, public readonly trigger: Trigger) {}
- public async execute(context: TriggerContext) {
+ public async execute(context: TriggerContextMapping[T]) {
const triggerId = this.trigger.id;
const actions = await this.service.getTriggerCompatibleActions!(triggerId, context);
@@ -51,7 +51,10 @@ export class TriggerInternal {
await this.executeMultipleActions(actions, context);
}
- private async executeSingleAction(action: Action>, context: TriggerContext) {
+ private async executeSingleAction(
+ action: Action,
+ context: TriggerContextMapping[T]
+ ) {
const href = action.getHref && action.getHref(context);
if (href) {
@@ -63,8 +66,8 @@ export class TriggerInternal {
}
private async executeMultipleActions(
- actions: Array>>,
- context: TriggerContext
+ actions: Array>,
+ context: TriggerContextMapping[T]
) {
const panel = await buildContextMenuForActions({
actions,
diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts
index 8daa893eb4347..d78d3c8951222 100644
--- a/src/plugins/ui_actions/public/types.ts
+++ b/src/plugins/ui_actions/public/types.ts
@@ -20,12 +20,17 @@
import { Action } from './actions/action';
import { TriggerInternal } from './triggers/trigger_internal';
-export type TriggerRegistry = Map>;
-export type ActionRegistry = Map;
-export type TriggerToActionsRegistry = Map;
+export type TriggerRegistry = Map>;
+export type ActionRegistry = Map>;
+export type TriggerToActionsRegistry = Map;
-export type TriggerId = string;
+const DEFAULT_TRIGGER = '';
+
+export type TriggerId = keyof TriggerContextMapping;
+
+export type TriggerContext = BaseContext;
+export type BaseContext = object | undefined | string | number;
export interface TriggerContextMapping {
- [key: string]: object;
+ [DEFAULT_TRIGGER]: TriggerContext;
}
diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/app.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/app.tsx
index dde58eaf44f88..144954800c91f 100644
--- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/app.tsx
+++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/app.tsx
@@ -23,12 +23,12 @@ import {
GetEmbeddableFactory,
GetEmbeddableFactories,
} from 'src/legacy/core_plugins/embeddable_api/public/np_ready/public';
-import { GetActionsCompatibleWithTrigger } from '../../../../../../../../src/plugins/ui_actions/public';
+import { UiActionsService } from '../../../../../../../../src/plugins/ui_actions/public';
import { DashboardContainerExample } from './dashboard_container_example';
import { Start as InspectorStartContract } from '../../../../../../../../src/plugins/inspector/public';
export interface AppProps {
- getActions: GetActionsCompatibleWithTrigger;
+ getActions: UiActionsService['getTriggerCompatibleActions'];
getEmbeddableFactory: GetEmbeddableFactory;
getAllEmbeddableFactories: GetEmbeddableFactories;
overlays: CoreStart['overlays'];
diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx
index 0237df63351cf..df0c00fb48b2e 100644
--- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx
+++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx
@@ -35,10 +35,10 @@ import {
import { CoreStart } from '../../../../../../../../src/core/public';
import { dashboardInput } from './dashboard_input';
import { Start as InspectorStartContract } from '../../../../../../../../src/plugins/inspector/public';
-import { GetActionsCompatibleWithTrigger } from '../../../../../../../../src/plugins/ui_actions/public';
+import { UiActionsService } from '../../../../../../../../src/plugins/ui_actions/public';
interface Props {
- getActions: GetActionsCompatibleWithTrigger;
+ getActions: UiActionsService['getTriggerCompatibleActions'];
getEmbeddableFactory: GetEmbeddableFactory;
getAllEmbeddableFactories: GetEmbeddableFactories;
overlays: CoreStart['overlays'];
From 8620f437d0ca303a3652139cc5ba828855584eca Mon Sep 17 00:00:00 2001
From: Lukas Olson
Date: Fri, 28 Feb 2020 10:15:09 -0700
Subject: [PATCH 15/31] [Autocomplete] Use settings from config rather than UI
settings (#58784)
* Update autocomplete to use settings from config rather than advanced settings
* Update terrible snapshot
Co-authored-by: Elastic Machine
---
src/core/server/mocks.ts | 6 +++++-
src/core/server/plugins/plugin_context.test.ts | 6 +++++-
src/core/server/plugins/types.ts | 2 +-
.../server/autocomplete/autocomplete_service.ts | 6 ++++--
src/plugins/data/server/autocomplete/routes.ts | 7 ++++---
.../autocomplete/value_suggestions_route.ts | 15 ++++++++++-----
src/plugins/data/server/plugin.ts | 3 ++-
7 files changed, 31 insertions(+), 14 deletions(-)
diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts
index b8380a3045962..96b28ab5827e1 100644
--- a/src/core/server/mocks.ts
+++ b/src/core/server/mocks.ts
@@ -44,7 +44,11 @@ import { uuidServiceMock } from './uuid/uuid_service.mock';
export function pluginInitializerContextConfigMock(config: T) {
const globalConfig: SharedGlobalConfig = {
- kibana: { index: '.kibana-tests' },
+ kibana: {
+ index: '.kibana-tests',
+ autocompleteTerminateAfter: duration(100000),
+ autocompleteTimeout: duration(1000),
+ },
elasticsearch: {
shardTimeout: duration('30s'),
requestTimeout: duration('30s'),
diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts
index 823299771544c..54350d96984b4 100644
--- a/src/core/server/plugins/plugin_context.test.ts
+++ b/src/core/server/plugins/plugin_context.test.ts
@@ -75,7 +75,11 @@ describe('Plugin Context', () => {
.pipe(first())
.toPromise();
expect(configObject).toStrictEqual({
- kibana: { index: '.kibana' },
+ kibana: {
+ index: '.kibana',
+ autocompleteTerminateAfter: duration(100000),
+ autocompleteTimeout: duration(1000),
+ },
elasticsearch: {
shardTimeout: duration(30, 's'),
requestTimeout: duration(30, 's'),
diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts
index e6a04c1223e6c..100e3c2288dbf 100644
--- a/src/core/server/plugins/types.ts
+++ b/src/core/server/plugins/types.ts
@@ -214,7 +214,7 @@ export interface Plugin<
export const SharedGlobalConfigKeys = {
// We can add more if really needed
- kibana: ['index'] as const,
+ kibana: ['index', 'autocompleteTerminateAfter', 'autocompleteTimeout'] as const,
elasticsearch: ['shardTimeout', 'requestTimeout', 'pingTimeout', 'startupTimeout'] as const,
path: ['data'] as const,
};
diff --git a/src/plugins/data/server/autocomplete/autocomplete_service.ts b/src/plugins/data/server/autocomplete/autocomplete_service.ts
index 1b85321aa2185..412e9b6236195 100644
--- a/src/plugins/data/server/autocomplete/autocomplete_service.ts
+++ b/src/plugins/data/server/autocomplete/autocomplete_service.ts
@@ -17,12 +17,14 @@
* under the License.
*/
-import { CoreSetup, Plugin } from 'kibana/server';
+import { CoreSetup, Plugin, PluginInitializerContext } from 'kibana/server';
import { registerRoutes } from './routes';
export class AutocompleteService implements Plugin {
+ constructor(private initializerContext: PluginInitializerContext) {}
+
public setup(core: CoreSetup) {
- registerRoutes(core);
+ registerRoutes(core, this.initializerContext.config.legacy.globalConfig$);
}
public start() {}
diff --git a/src/plugins/data/server/autocomplete/routes.ts b/src/plugins/data/server/autocomplete/routes.ts
index 9134287d2b8ff..b7fd00947ce0e 100644
--- a/src/plugins/data/server/autocomplete/routes.ts
+++ b/src/plugins/data/server/autocomplete/routes.ts
@@ -17,11 +17,12 @@
* under the License.
*/
-import { CoreSetup } from 'kibana/server';
+import { Observable } from 'rxjs';
+import { CoreSetup, SharedGlobalConfig } from 'kibana/server';
import { registerValueSuggestionsRoute } from './value_suggestions_route';
-export function registerRoutes({ http }: CoreSetup): void {
+export function registerRoutes({ http }: CoreSetup, config$: Observable): void {
const router = http.createRouter();
- registerValueSuggestionsRoute(router);
+ registerValueSuggestionsRoute(router, config$);
}
diff --git a/src/plugins/data/server/autocomplete/value_suggestions_route.ts b/src/plugins/data/server/autocomplete/value_suggestions_route.ts
index 02a5e0921fe4f..03dbd40984412 100644
--- a/src/plugins/data/server/autocomplete/value_suggestions_route.ts
+++ b/src/plugins/data/server/autocomplete/value_suggestions_route.ts
@@ -19,13 +19,18 @@
import { get, map } from 'lodash';
import { schema } from '@kbn/config-schema';
-import { IRouter } from 'kibana/server';
+import { IRouter, SharedGlobalConfig } from 'kibana/server';
+import { Observable } from 'rxjs';
+import { first } from 'rxjs/operators';
import { IFieldType, Filter } from '../index';
import { findIndexPatternById, getFieldByName } from '../index_patterns';
import { getRequestAbortedSignal } from '../lib';
-export function registerValueSuggestionsRoute(router: IRouter) {
+export function registerValueSuggestionsRoute(
+ router: IRouter,
+ config$: Observable
+) {
router.post(
{
path: '/api/kibana/suggestions/values/{index}',
@@ -47,15 +52,15 @@ export function registerValueSuggestionsRoute(router: IRouter) {
},
},
async (context, request, response) => {
- const { client: uiSettings } = context.core.uiSettings;
+ const config = await config$.pipe(first()).toPromise();
const { field: fieldName, query, boolFilter } = request.body;
const { index } = request.params;
const { dataClient } = context.core.elasticsearch;
const signal = getRequestAbortedSignal(request.events.aborted$);
const autocompleteSearchOptions = {
- timeout: await uiSettings.get('kibana.autocompleteTimeout'),
- terminate_after: await uiSettings.get('kibana.autocompleteTerminateAfter'),
+ timeout: `${config.kibana.autocompleteTimeout.asMilliseconds()}ms`,
+ terminate_after: config.kibana.autocompleteTerminateAfter.asMilliseconds(),
};
const indexPattern = await findIndexPatternById(context.core.savedObjects.client, index);
diff --git a/src/plugins/data/server/plugin.ts b/src/plugins/data/server/plugin.ts
index fcd3b62b2ec67..616e65ad872ab 100644
--- a/src/plugins/data/server/plugin.ts
+++ b/src/plugins/data/server/plugin.ts
@@ -44,7 +44,7 @@ export class DataServerPlugin implements Plugin
Date: Fri, 28 Feb 2020 13:08:37 -0500
Subject: [PATCH 16/31] [Lens] Allow number formatting within Lens (#56253)
* [Lens] Allow custom number formats on dimensions
* Fix merge issues
* Text and decimal changes from review
* Persist number format across operations
* Respond to review comments
* Change label
* Add persistence
* Fix import
* 2 decimals
* Persist number formatting on drop too
Co-authored-by: Elastic Machine
---
.../data/common/field_formats/types.ts | 1 +
.../editor_frame_service/format_column.ts | 92 +++++++
.../public/editor_frame_service/service.tsx | 2 +
.../dimension_panel/dimension_panel.test.tsx | 253 ++++++++++++++++--
.../dimension_panel/dimension_panel.tsx | 3 +
.../dimension_panel/format_selector.tsx | 136 ++++++++++
.../dimension_panel/popover_editor.tsx | 24 +-
.../indexpattern.test.ts | 2 +-
.../indexpattern_datasource/indexpattern.tsx | 1 +
.../operations/definitions/cardinality.tsx | 8 +-
.../operations/definitions/column_types.ts | 12 +
.../operations/definitions/count.tsx | 13 +-
.../operations/definitions/index.ts | 3 +-
.../operations/definitions/metrics.tsx | 20 +-
.../operations/operations.ts | 7 +-
.../state_helpers.test.ts | 41 +++
.../indexpattern_datasource/state_helpers.ts | 13 +-
.../indexpattern_datasource/to_expression.ts | 17 +-
18 files changed, 600 insertions(+), 48 deletions(-)
create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_service/format_column.ts
create mode 100644 x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx
diff --git a/src/plugins/data/common/field_formats/types.ts b/src/plugins/data/common/field_formats/types.ts
index 0c16d9f1ac8bf..7c1d6a8522e52 100644
--- a/src/plugins/data/common/field_formats/types.ts
+++ b/src/plugins/data/common/field_formats/types.ts
@@ -87,6 +87,7 @@ export type IFieldFormatType = (new (
getConfig?: FieldFormatsGetConfigFn
) => FieldFormat) & {
id: FieldFormatId;
+ title: string;
fieldType: string | string[];
};
diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/format_column.ts b/x-pack/legacy/plugins/lens/public/editor_frame_service/format_column.ts
new file mode 100644
index 0000000000000..dfb725fef49bb
--- /dev/null
+++ b/x-pack/legacy/plugins/lens/public/editor_frame_service/format_column.ts
@@ -0,0 +1,92 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ExpressionFunctionDefinition, KibanaDatatable } from 'src/plugins/expressions/public';
+
+interface FormatColumn {
+ format: string;
+ columnId: string;
+ decimals?: number;
+}
+
+const supportedFormats: Record string }> = {
+ number: {
+ decimalsToPattern: (decimals = 2) => {
+ if (decimals === 0) {
+ return `0,0`;
+ }
+ return `0,0.${'0'.repeat(decimals)}`;
+ },
+ },
+ percent: {
+ decimalsToPattern: (decimals = 2) => {
+ if (decimals === 0) {
+ return `0,0%`;
+ }
+ return `0,0.${'0'.repeat(decimals)}%`;
+ },
+ },
+ bytes: {
+ decimalsToPattern: (decimals = 2) => {
+ if (decimals === 0) {
+ return `0,0b`;
+ }
+ return `0,0.${'0'.repeat(decimals)}b`;
+ },
+ },
+};
+
+export const formatColumn: ExpressionFunctionDefinition<
+ 'lens_format_column',
+ KibanaDatatable,
+ FormatColumn,
+ KibanaDatatable
+> = {
+ name: 'lens_format_column',
+ type: 'kibana_datatable',
+ help: '',
+ args: {
+ format: {
+ types: ['string'],
+ help: '',
+ required: true,
+ },
+ columnId: {
+ types: ['string'],
+ help: '',
+ required: true,
+ },
+ decimals: {
+ types: ['number'],
+ help: '',
+ },
+ },
+ inputTypes: ['kibana_datatable'],
+ fn(input, { format, columnId, decimals }: FormatColumn) {
+ return {
+ ...input,
+ columns: input.columns.map(col => {
+ if (col.id === columnId) {
+ if (supportedFormats[format]) {
+ return {
+ ...col,
+ formatHint: {
+ id: format,
+ params: { pattern: supportedFormats[format].decimalsToPattern(decimals) },
+ },
+ };
+ } else {
+ return {
+ ...col,
+ formatHint: { id: format, params: {} },
+ };
+ }
+ }
+ return col;
+ }),
+ };
+ },
+};
diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_service/service.tsx
index 7a0bb3a2cc50f..5347be47e145e 100644
--- a/x-pack/legacy/plugins/lens/public/editor_frame_service/service.tsx
+++ b/x-pack/legacy/plugins/lens/public/editor_frame_service/service.tsx
@@ -29,6 +29,7 @@ import {
} from '../types';
import { EditorFrame } from './editor_frame';
import { mergeTables } from './merge_tables';
+import { formatColumn } from './format_column';
import { EmbeddableFactory } from './embeddable/embeddable_factory';
import { getActiveDatasourceIdFromDoc } from './editor_frame/state_management';
@@ -64,6 +65,7 @@ export class EditorFrameService {
public setup(core: CoreSetup, plugins: EditorFrameSetupPlugins): EditorFrameSetup {
plugins.expressions.registerFunction(() => mergeTables);
+ plugins.expressions.registerFunction(() => formatColumn);
return {
registerDatasource: datasource => {
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 98cf862e1fd2b..56f75ae4b17be 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
@@ -7,7 +7,14 @@
import { ReactWrapper, ShallowWrapper } from 'enzyme';
import React from 'react';
import { act } from 'react-dom/test-utils';
-import { EuiComboBox, EuiSideNav, EuiSideNavItemType, EuiPopover } from '@elastic/eui';
+import {
+ EuiComboBox,
+ EuiSideNav,
+ EuiSideNavItemType,
+ EuiPopover,
+ EuiFieldNumber,
+} from '@elastic/eui';
+import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public';
import { changeColumn } from '../state_helpers';
import {
IndexPatternDimensionPanel,
@@ -139,6 +146,18 @@ describe('IndexPatternDimensionPanel', () => {
uiSettings: {} as IUiSettingsClient,
savedObjectsClient: {} as SavedObjectsClientContract,
http: {} as HttpSetup,
+ data: ({
+ fieldFormats: ({
+ getType: jest.fn().mockReturnValue({
+ id: 'number',
+ title: 'Number',
+ }),
+ getDefaultType: jest.fn().mockReturnValue({
+ id: 'bytes',
+ title: 'Bytes',
+ }),
+ } as unknown) as DataPublicPluginStart['fieldFormats'],
+ } as unknown) as DataPublicPluginStart,
};
jest.clearAllMocks();
@@ -175,7 +194,9 @@ describe('IndexPatternDimensionPanel', () => {
openPopover();
- expect(wrapper.find(EuiComboBox)).toHaveLength(1);
+ expect(
+ wrapper.find(EuiComboBox).filter('[data-test-subj="indexPattern-dimension-field"]')
+ ).toHaveLength(1);
});
it('should not show any choices if the filter returns false', () => {
@@ -189,7 +210,12 @@ describe('IndexPatternDimensionPanel', () => {
openPopover();
- expect(wrapper.find(EuiComboBox)!.prop('options')!).toHaveLength(0);
+ expect(
+ wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-field"]')!
+ .prop('options')!
+ ).toHaveLength(0);
});
it('should list all field names and document as a whole in prioritized order', () => {
@@ -197,7 +223,10 @@ describe('IndexPatternDimensionPanel', () => {
openPopover();
- const options = wrapper.find(EuiComboBox).prop('options');
+ const options = wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-field"]')
+ .prop('options');
expect(options).toHaveLength(2);
@@ -228,7 +257,10 @@ describe('IndexPatternDimensionPanel', () => {
openPopover();
- const options = wrapper.find(EuiComboBox).prop('options');
+ const options = wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-field"]')
+ .prop('options');
expect(options![1].options!.map(({ label }) => label)).toEqual(['timestamp', 'source']);
});
@@ -262,7 +294,10 @@ describe('IndexPatternDimensionPanel', () => {
openPopover();
- const options = wrapper.find(EuiComboBox).prop('options');
+ const options = wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-field"]')
+ .prop('options');
expect(options![0]['data-test-subj']).toEqual('lns-fieldOptionIncompatible-Records');
@@ -335,6 +370,7 @@ describe('IndexPatternDimensionPanel', () => {
// Private
operationType: 'max',
sourceField: 'bytes',
+ params: { format: { id: 'bytes' } },
},
},
},
@@ -345,7 +381,9 @@ describe('IndexPatternDimensionPanel', () => {
openPopover();
- const comboBox = wrapper.find(EuiComboBox)!;
+ const comboBox = wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-field"]')!;
const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'memory')!;
act(() => {
@@ -362,6 +400,7 @@ describe('IndexPatternDimensionPanel', () => {
col1: expect.objectContaining({
operationType: 'max',
sourceField: 'memory',
+ params: { format: { id: 'bytes' } },
// Other parts of this don't matter for this test
}),
},
@@ -375,7 +414,9 @@ describe('IndexPatternDimensionPanel', () => {
openPopover();
- const comboBox = wrapper.find(EuiComboBox)!;
+ const comboBox = wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-field"]')!;
const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'source')!;
act(() => {
@@ -419,6 +460,7 @@ describe('IndexPatternDimensionPanel', () => {
// Private
operationType: 'max',
sourceField: 'bytes',
+ params: { format: { id: 'bytes' } },
},
},
},
@@ -443,6 +485,7 @@ describe('IndexPatternDimensionPanel', () => {
col1: expect.objectContaining({
operationType: 'min',
sourceField: 'bytes',
+ params: { format: { id: 'bytes' } },
// Other parts of this don't matter for this test
}),
},
@@ -565,7 +608,10 @@ describe('IndexPatternDimensionPanel', () => {
.find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]')
.simulate('click');
- const options = wrapper.find(EuiComboBox).prop('options');
+ const options = wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-field"]')
+ .prop('options');
expect(options![0]['data-test-subj']).toContain('Incompatible');
@@ -584,7 +630,9 @@ describe('IndexPatternDimensionPanel', () => {
wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click');
- const comboBox = wrapper.find(EuiComboBox);
+ const comboBox = wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-field"]');
const options = comboBox.prop('options');
// options[1][2] is a `source` field of type `string` which doesn't support `avg` operation
@@ -674,7 +722,10 @@ describe('IndexPatternDimensionPanel', () => {
.find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]')
.simulate('click');
- const options = wrapper.find(EuiComboBox).prop('options');
+ const options = wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-field"]')
+ .prop('options');
expect(options![0]['data-test-subj']).toContain('Incompatible');
@@ -697,7 +748,9 @@ describe('IndexPatternDimensionPanel', () => {
.simulate('click');
});
- const comboBox = wrapper.find(EuiComboBox)!;
+ const comboBox = wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-field"]')!;
const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'source')!;
act(() => {
@@ -729,7 +782,9 @@ describe('IndexPatternDimensionPanel', () => {
wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click');
- const comboBox = wrapper.find(EuiComboBox);
+ const comboBox = wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-field"]');
const options = comboBox.prop('options');
act(() => {
@@ -825,7 +880,10 @@ describe('IndexPatternDimensionPanel', () => {
wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click');
- const options = wrapper.find(EuiComboBox).prop('options');
+ const options = wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-field"]')
+ .prop('options');
expect(options![0]['data-test-subj']).toContain('Incompatible');
@@ -865,7 +923,10 @@ describe('IndexPatternDimensionPanel', () => {
openPopover();
- const options = wrapper.find(EuiComboBox).prop('options');
+ const options = wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-field"]')
+ .prop('options');
expect(options![0]['data-test-subj']).not.toContain('Incompatible');
@@ -905,7 +966,9 @@ describe('IndexPatternDimensionPanel', () => {
openPopover();
- const comboBox = wrapper.find(EuiComboBox)!;
+ const comboBox = wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-field"]')!;
const option = comboBox.prop('options')![1].options![0];
act(() => {
@@ -1002,7 +1065,10 @@ describe('IndexPatternDimensionPanel', () => {
openPopover();
act(() => {
- wrapper.find(EuiComboBox).prop('onChange')!([]);
+ wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-field"]')
+ .prop('onChange')!([]);
});
expect(setState).toHaveBeenCalledWith({
@@ -1017,6 +1083,159 @@ describe('IndexPatternDimensionPanel', () => {
});
});
+ it('allows custom format', () => {
+ const stateWithNumberCol: IndexPatternPrivateState = {
+ ...state,
+ layers: {
+ first: {
+ indexPatternId: '1',
+ columnOrder: ['col1'],
+ columns: {
+ col1: {
+ label: 'Average of bar',
+ dataType: 'number',
+ isBucketed: false,
+ // Private
+ operationType: 'avg',
+ sourceField: 'bar',
+ },
+ },
+ },
+ },
+ };
+
+ wrapper = mount();
+
+ openPopover();
+
+ act(() => {
+ wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-format"]')
+ .prop('onChange')!([{ value: 'bytes', label: 'Bytes' }]);
+ });
+
+ expect(setState).toHaveBeenCalledWith({
+ ...state,
+ layers: {
+ first: {
+ ...state.layers.first,
+ columns: {
+ ...state.layers.first.columns,
+ col1: expect.objectContaining({
+ params: {
+ format: { id: 'bytes', params: { decimals: 2 } },
+ },
+ }),
+ },
+ },
+ },
+ });
+ });
+
+ it('keeps decimal places while switching', () => {
+ const stateWithNumberCol: IndexPatternPrivateState = {
+ ...state,
+ layers: {
+ first: {
+ indexPatternId: '1',
+ columnOrder: ['col1'],
+ columns: {
+ col1: {
+ label: 'Average of bar',
+ dataType: 'number',
+ isBucketed: false,
+ // Private
+ operationType: 'avg',
+ sourceField: 'bar',
+ params: {
+ format: { id: 'bytes', params: { decimals: 0 } },
+ },
+ },
+ },
+ },
+ },
+ };
+
+ wrapper = mount();
+
+ openPopover();
+
+ act(() => {
+ wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-format"]')
+ .prop('onChange')!([{ value: '', label: 'Default' }]);
+ });
+
+ act(() => {
+ wrapper
+ .find(EuiComboBox)
+ .filter('[data-test-subj="indexPattern-dimension-format"]')
+ .prop('onChange')!([{ value: 'number', label: 'Number' }]);
+ });
+
+ expect(
+ wrapper
+ .find(EuiFieldNumber)
+ .filter('[data-test-subj="indexPattern-dimension-formatDecimals"]')
+ .prop('value')
+ ).toEqual(0);
+ });
+
+ it('allows custom format with number of decimal places', () => {
+ const stateWithNumberCol: IndexPatternPrivateState = {
+ ...state,
+ layers: {
+ first: {
+ indexPatternId: '1',
+ columnOrder: ['col1'],
+ columns: {
+ col1: {
+ label: 'Average of bar',
+ dataType: 'number',
+ isBucketed: false,
+ // Private
+ operationType: 'avg',
+ sourceField: 'bar',
+ params: {
+ format: { id: 'bytes', params: { decimals: 2 } },
+ },
+ },
+ },
+ },
+ },
+ };
+
+ wrapper = mount();
+
+ openPopover();
+
+ act(() => {
+ wrapper
+ .find(EuiFieldNumber)
+ .filter('[data-test-subj="indexPattern-dimension-formatDecimals"]')
+ .prop('onChange')!({ target: { value: '0' } });
+ });
+
+ expect(setState).toHaveBeenCalledWith({
+ ...state,
+ layers: {
+ first: {
+ ...state.layers.first,
+ columns: {
+ ...state.layers.first.columns,
+ col1: expect.objectContaining({
+ params: {
+ format: { id: 'bytes', params: { decimals: 0 } },
+ },
+ }),
+ },
+ },
+ },
+ });
+ });
+
describe('drag and drop', () => {
function dragDropState(): IndexPatternPrivateState {
return {
diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx
index 972c396f93b43..59350ff215c27 100644
--- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx
+++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx
@@ -10,6 +10,7 @@ import { EuiButtonIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'src/core/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
+import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public';
import { DatasourceDimensionPanelProps, StateSetter } from '../../types';
import { IndexPatternColumn, OperationType } from '../indexpattern';
import { getAvailableOperationsByMetadata, buildColumn, changeField } from '../operations';
@@ -30,6 +31,7 @@ export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & {
savedObjectsClient: SavedObjectsClientContract;
layerId: string;
http: HttpSetup;
+ data: DataPublicPluginStart;
uniqueLabel: string;
dateRange: DateRange;
};
@@ -128,6 +130,7 @@ export const IndexPatternDimensionPanelComponent = function IndexPatternDimensio
layerId,
suggestedPriority: props.suggestedPriority,
field: droppedItem.field,
+ previousColumn: selectedColumn,
});
trackUiEvent('drop_onto_dimension');
diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx
new file mode 100644
index 0000000000000..ed68a93c51ca2
--- /dev/null
+++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx
@@ -0,0 +1,136 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useState } from 'react';
+import { i18n } from '@kbn/i18n';
+import { EuiFormRow, EuiFieldNumber, EuiComboBox } from '@elastic/eui';
+import { IndexPatternColumn } from '../indexpattern';
+
+const supportedFormats: Record = {
+ number: {
+ title: i18n.translate('xpack.lens.indexPattern.numberFormatLabel', {
+ defaultMessage: 'Number',
+ }),
+ },
+ percent: {
+ title: i18n.translate('xpack.lens.indexPattern.percentFormatLabel', {
+ defaultMessage: 'Percent',
+ }),
+ },
+ bytes: {
+ title: i18n.translate('xpack.lens.indexPattern.bytesFormatLabel', {
+ defaultMessage: 'Bytes (1024)',
+ }),
+ },
+};
+
+interface FormatSelectorProps {
+ selectedColumn: IndexPatternColumn;
+ onChange: (newFormat?: { id: string; params?: Record }) => void;
+}
+
+interface State {
+ decimalPlaces: number;
+}
+
+export function FormatSelector(props: FormatSelectorProps) {
+ const { selectedColumn, onChange } = props;
+
+ const currentFormat =
+ 'params' in selectedColumn && selectedColumn.params && 'format' in selectedColumn.params
+ ? selectedColumn.params.format
+ : undefined;
+ const [state, setState] = useState({
+ decimalPlaces:
+ typeof currentFormat?.params?.decimals === 'number' ? currentFormat.params.decimals : 2,
+ });
+
+ const selectedFormat = currentFormat?.id ? supportedFormats[currentFormat.id] : undefined;
+
+ const defaultOption = {
+ value: '',
+ label: i18n.translate('xpack.lens.indexPattern.defaultFormatLabel', {
+ defaultMessage: 'Default',
+ }),
+ };
+
+ return (
+ <>
+
+