;
export interface ExternalServiceCommentResponse {
commentId: string;
@@ -29,18 +27,6 @@ export interface ExternalServiceCommentResponse {
externalCommentId?: string;
}
-export type ExecutorSubActionGetIncidentParams = TypeOf<
- typeof ExecutorSubActionGetIncidentParamsSchema
->;
-
-export type ExecutorSubActionHandshakeParams = TypeOf<
- typeof ExecutorSubActionHandshakeParamsSchema
->;
-
-export interface PushToServiceResponse extends ExternalServiceIncidentResponse {
- comments?: ExternalServiceCommentResponse[];
-}
-
export interface PipedField {
key: string;
value: string;
@@ -48,10 +34,10 @@ export interface PipedField {
pipes: string[];
}
-export interface TransformFieldsArgs {
- params: PushToServiceApiParams;
+export interface TransformFieldsArgs {
+ params: P;
fields: PipedField[];
- currentIncident?: ExternalServiceParams;
+ currentIncident?: S;
}
export interface TransformerArgs {
diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts
index d895bf386a367..701bbea14fde8 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts
@@ -51,10 +51,7 @@ export const buildMap = (mapping: MapRecord[]): Map => {
}, new Map());
};
-export const mapParams = (
- params: Partial,
- mapping: Map
-): AnyParams => {
+export const mapParams = (params: T, mapping: Map): AnyParams => {
return Object.keys(params).reduce((prev: AnyParams, curr: string): AnyParams => {
const field = mapping.get(curr);
if (field) {
@@ -106,7 +103,10 @@ export const createConnectorExecutor = ({
const { comments, externalId, ...restParams } = pushToServiceParams;
const mapping = buildMap(config.casesConfiguration.mapping);
- const externalCase = mapParams(restParams, mapping);
+ const externalCase = mapParams(
+ restParams as ExecutorSubActionPushParams,
+ mapping
+ );
data = await api.pushToService({
externalService,
diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts
index bcfb82077d286..4495c37f758ee 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts
@@ -4,15 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { api } from '../case/api';
+import { Logger } from '../../../../../../src/core/server';
import { externalServiceMock, mapping, apiParams } from './mocks';
-import { ExternalService } from '../case/types';
+import { ExternalService } from './types';
+import { api } from './api';
+let mockedLogger: jest.Mocked;
describe('api', () => {
let externalService: jest.Mocked;
beforeEach(() => {
externalService = externalServiceMock.create();
+ jest.clearAllMocks();
});
afterEach(() => {
@@ -20,10 +23,15 @@ describe('api', () => {
});
describe('pushToService', () => {
- describe('create incident', () => {
+ describe('create incident - cases', () => {
test('it creates an incident', async () => {
const params = { ...apiParams, externalId: null };
- const res = await api.pushToService({ externalService, mapping, params });
+ const res = await api.pushToService({
+ externalService,
+ mapping,
+ params,
+ logger: mockedLogger,
+ });
expect(res).toEqual({
id: 'incident-1',
@@ -45,7 +53,12 @@ describe('api', () => {
test('it creates an incident without comments', async () => {
const params = { ...apiParams, externalId: null, comments: [] };
- const res = await api.pushToService({ externalService, mapping, params });
+ const res = await api.pushToService({
+ externalService,
+ mapping,
+ params,
+ logger: mockedLogger,
+ });
expect(res).toEqual({
id: 'incident-1',
@@ -57,7 +70,7 @@ describe('api', () => {
test('it calls createIncident correctly', async () => {
const params = { ...apiParams, externalId: null };
- await api.pushToService({ externalService, mapping, params });
+ await api.pushToService({ externalService, mapping, params, logger: mockedLogger });
expect(externalService.createIncident).toHaveBeenCalledWith({
incident: {
@@ -69,9 +82,25 @@ describe('api', () => {
expect(externalService.updateIncident).not.toHaveBeenCalled();
});
+ test('it calls createIncident correctly without mapping', async () => {
+ const params = { ...apiParams, externalId: null };
+ await api.pushToService({ externalService, mapping: null, params, logger: mockedLogger });
+
+ expect(externalService.createIncident).toHaveBeenCalledWith({
+ incident: {
+ description: 'Incident description',
+ summary: 'Incident title',
+ issueType: '10006',
+ labels: ['kibana', 'elastic'],
+ priority: 'High',
+ },
+ });
+ expect(externalService.updateIncident).not.toHaveBeenCalled();
+ });
+
test('it calls createComment correctly', async () => {
const params = { ...apiParams, externalId: null };
- await api.pushToService({ externalService, mapping, params });
+ await api.pushToService({ externalService, mapping, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenCalledTimes(2);
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
incidentId: 'incident-1',
@@ -89,7 +118,6 @@ describe('api', () => {
username: 'elastic',
},
},
- field: 'comments',
});
expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
@@ -108,14 +136,59 @@ describe('api', () => {
username: 'elastic',
},
},
- field: 'comments',
+ });
+ });
+
+ test('it calls createComment correctly without mapping', async () => {
+ const params = { ...apiParams, externalId: null };
+ await api.pushToService({ externalService, mapping: null, params, logger: mockedLogger });
+ expect(externalService.createComment).toHaveBeenCalledTimes(2);
+ expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
+ incidentId: 'incident-1',
+ comment: {
+ commentId: 'case-comment-1',
+ comment: 'A comment',
+ createdAt: '2020-04-27T10:59:46.202Z',
+ createdBy: {
+ fullName: 'Elastic User',
+ username: 'elastic',
+ },
+ updatedAt: '2020-04-27T10:59:46.202Z',
+ updatedBy: {
+ fullName: 'Elastic User',
+ username: 'elastic',
+ },
+ },
+ });
+
+ expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
+ incidentId: 'incident-1',
+ comment: {
+ commentId: 'case-comment-2',
+ comment: 'Another comment',
+ createdAt: '2020-04-27T10:59:46.202Z',
+ createdBy: {
+ fullName: 'Elastic User',
+ username: 'elastic',
+ },
+ updatedAt: '2020-04-27T10:59:46.202Z',
+ updatedBy: {
+ fullName: 'Elastic User',
+ username: 'elastic',
+ },
+ },
});
});
});
describe('update incident', () => {
test('it updates an incident', async () => {
- const res = await api.pushToService({ externalService, mapping, params: apiParams });
+ const res = await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ logger: mockedLogger,
+ });
expect(res).toEqual({
id: 'incident-1',
@@ -137,7 +210,12 @@ describe('api', () => {
test('it updates an incident without comments', async () => {
const params = { ...apiParams, comments: [] };
- const res = await api.pushToService({ externalService, mapping, params });
+ const res = await api.pushToService({
+ externalService,
+ mapping,
+ params,
+ logger: mockedLogger,
+ });
expect(res).toEqual({
id: 'incident-1',
@@ -149,7 +227,7 @@ describe('api', () => {
test('it calls updateIncident correctly', async () => {
const params = { ...apiParams };
- await api.pushToService({ externalService, mapping, params });
+ await api.pushToService({ externalService, mapping, params, logger: mockedLogger });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
@@ -162,9 +240,26 @@ describe('api', () => {
expect(externalService.createIncident).not.toHaveBeenCalled();
});
+ test('it calls updateIncident correctly without mapping', async () => {
+ const params = { ...apiParams };
+ await api.pushToService({ externalService, mapping: null, params, logger: mockedLogger });
+
+ expect(externalService.updateIncident).toHaveBeenCalledWith({
+ incidentId: 'incident-3',
+ incident: {
+ description: 'Incident description',
+ summary: 'Incident title',
+ issueType: '10006',
+ labels: ['kibana', 'elastic'],
+ priority: 'High',
+ },
+ });
+ expect(externalService.createIncident).not.toHaveBeenCalled();
+ });
+
test('it calls createComment correctly', async () => {
const params = { ...apiParams };
- await api.pushToService({ externalService, mapping, params });
+ await api.pushToService({ externalService, mapping, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenCalledTimes(2);
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
incidentId: 'incident-1',
@@ -182,7 +277,6 @@ describe('api', () => {
username: 'elastic',
},
},
- field: 'comments',
});
expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
@@ -201,7 +295,87 @@ describe('api', () => {
username: 'elastic',
},
},
- field: 'comments',
+ });
+ });
+
+ test('it calls createComment correctly without mapping', async () => {
+ const params = { ...apiParams };
+ await api.pushToService({ externalService, mapping: null, params, logger: mockedLogger });
+ expect(externalService.createComment).toHaveBeenCalledTimes(2);
+ expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
+ incidentId: 'incident-1',
+ comment: {
+ commentId: 'case-comment-1',
+ comment: 'A comment',
+ createdAt: '2020-04-27T10:59:46.202Z',
+ createdBy: {
+ fullName: 'Elastic User',
+ username: 'elastic',
+ },
+ updatedAt: '2020-04-27T10:59:46.202Z',
+ updatedBy: {
+ fullName: 'Elastic User',
+ username: 'elastic',
+ },
+ },
+ });
+
+ expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
+ incidentId: 'incident-1',
+ comment: {
+ commentId: 'case-comment-2',
+ comment: 'Another comment',
+ createdAt: '2020-04-27T10:59:46.202Z',
+ createdBy: {
+ fullName: 'Elastic User',
+ username: 'elastic',
+ },
+ updatedAt: '2020-04-27T10:59:46.202Z',
+ updatedBy: {
+ fullName: 'Elastic User',
+ username: 'elastic',
+ },
+ },
+ });
+ });
+ });
+
+ describe('issueTypes', () => {
+ test('it returns the issue types correctly', async () => {
+ const res = await api.issueTypes({
+ externalService,
+ params: {},
+ });
+ expect(res).toEqual([
+ {
+ id: '10006',
+ name: 'Task',
+ },
+ {
+ id: '10007',
+ name: 'Bug',
+ },
+ ]);
+ });
+ });
+
+ describe('fieldsByIssueType', () => {
+ test('it returns the fields correctly', async () => {
+ const res = await api.fieldsByIssueType({
+ externalService,
+ params: { id: '10006' },
+ });
+ expect(res).toEqual({
+ summary: { allowedValues: [], defaultValue: {} },
+ priority: {
+ allowedValues: [
+ {
+ name: 'Medium',
+ id: '3',
+ },
+ ],
+ defaultValue: { name: 'Medium', id: '3' },
+ },
});
});
});
@@ -228,7 +402,12 @@ describe('api', () => {
actionType: 'overwrite',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@@ -260,7 +439,12 @@ describe('api', () => {
actionType: 'nothing',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@@ -291,7 +475,12 @@ describe('api', () => {
actionType: 'append',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@@ -324,7 +513,12 @@ describe('api', () => {
actionType: 'nothing',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {},
@@ -352,7 +546,12 @@ describe('api', () => {
actionType: 'overwrite',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@@ -382,7 +581,12 @@ describe('api', () => {
actionType: 'overwrite',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@@ -414,7 +618,12 @@ describe('api', () => {
actionType: 'nothing',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@@ -445,7 +654,12 @@ describe('api', () => {
actionType: 'append',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@@ -478,7 +692,12 @@ describe('api', () => {
actionType: 'append',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ logger: mockedLogger,
+ });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@@ -509,7 +728,12 @@ describe('api', () => {
actionType: 'overwrite',
});
- await api.pushToService({ externalService, mapping, params: apiParams });
+ await api.pushToService({
+ externalService,
+ mapping,
+ params: apiParams,
+ logger: mockedLogger,
+ });
expect(externalService.createComment).not.toHaveBeenCalled();
});
});
diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts
index 3db66e5884af4..da47a4bfb839b 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts
@@ -4,4 +4,179 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export { api } from '../case/api';
+import { flow } from 'lodash';
+import {
+ ExternalServiceParams,
+ PushToServiceApiHandlerArgs,
+ HandshakeApiHandlerArgs,
+ GetIncidentApiHandlerArgs,
+ ExternalServiceApi,
+ Incident,
+ GetFieldsByIssueTypeHandlerArgs,
+ GetIssueTypesHandlerArgs,
+ PushToServiceApiParams,
+} from './types';
+
+// TODO: to remove, need to support Case
+import { transformers } from '../case/transformers';
+import { TransformFieldsArgs, Comment, EntityInformation } from '../case/common_types';
+
+import { PushToServiceResponse } from './types';
+import { prepareFieldsForTransformation } from '../case/utils';
+
+const handshakeHandler = async ({
+ externalService,
+ mapping,
+ params,
+}: HandshakeApiHandlerArgs) => {};
+
+const getIncidentHandler = async ({
+ externalService,
+ mapping,
+ params,
+}: GetIncidentApiHandlerArgs) => {};
+
+const getIssueTypesHandler = async ({ externalService }: GetIssueTypesHandlerArgs) => {
+ const res = await externalService.getIssueTypes();
+ return res;
+};
+
+const getFieldsByIssueTypeHandler = async ({
+ externalService,
+ params,
+}: GetFieldsByIssueTypeHandlerArgs) => {
+ const { id } = params;
+ const res = await externalService.getFieldsByIssueType(id);
+ return res;
+};
+
+const pushToServiceHandler = async ({
+ externalService,
+ mapping,
+ params,
+ logger,
+}: PushToServiceApiHandlerArgs): Promise => {
+ const { externalId, comments } = params;
+ const updateIncident = externalId ? true : false;
+ const defaultPipes = updateIncident ? ['informationUpdated'] : ['informationCreated'];
+ let currentIncident: ExternalServiceParams | undefined;
+ let res: PushToServiceResponse;
+
+ if (externalId) {
+ try {
+ currentIncident = await externalService.getIncident(externalId);
+ } catch (ex) {
+ logger.debug(
+ `Retrieving Incident by id ${externalId} from Jira failed with exception: ${ex}`
+ );
+ }
+ }
+
+ let incident: Incident;
+ // TODO: should be removed later but currently keep it for the Case implementation support
+ if (mapping) {
+ const fields = prepareFieldsForTransformation({
+ externalCase: params.externalObject,
+ mapping,
+ defaultPipes,
+ });
+
+ incident = transformFields({
+ params,
+ fields,
+ currentIncident,
+ });
+ } else {
+ const { title, description, priority, labels, issueType } = params;
+ incident = { summary: title, description, priority, labels, issueType };
+ }
+
+ if (externalId != null) {
+ res = await externalService.updateIncident({
+ incidentId: externalId,
+ incident,
+ });
+ } else {
+ res = await externalService.createIncident({
+ incident: {
+ ...incident,
+ },
+ });
+ }
+
+ if (comments && Array.isArray(comments) && comments.length > 0) {
+ if (mapping && mapping.get('comments')?.actionType === 'nothing') {
+ return res;
+ }
+
+ const commentsTransformed = mapping
+ ? transformComments(comments, ['informationAdded'])
+ : comments;
+
+ res.comments = [];
+ for (const currentComment of commentsTransformed) {
+ const comment = await externalService.createComment({
+ incidentId: res.id,
+ comment: currentComment,
+ });
+ res.comments = [
+ ...(res.comments ?? []),
+ {
+ commentId: comment.commentId,
+ pushedDate: comment.pushedDate,
+ },
+ ];
+ }
+ }
+
+ return res;
+};
+
+export const transformFields = ({
+ params,
+ fields,
+ currentIncident,
+}: TransformFieldsArgs): Incident => {
+ return fields.reduce((prev, cur) => {
+ const transform = flow(...cur.pipes.map((p) => transformers[p]));
+ return {
+ ...prev,
+ [cur.key]: transform({
+ value: cur.value,
+ date: params.updatedAt ?? params.createdAt,
+ user: getEntity(params),
+ previousValue: currentIncident ? currentIncident[cur.key] : '',
+ }).value,
+ };
+ }, {} as Incident);
+};
+
+export const transformComments = (comments: Comment[], pipes: string[]): Comment[] => {
+ return comments.map((c) => ({
+ ...c,
+ comment: flow(...pipes.map((p) => transformers[p]))({
+ value: c.comment,
+ date: c.updatedAt ?? c.createdAt,
+ user: getEntity(c),
+ }).value,
+ }));
+};
+
+export const getEntity = (entity: EntityInformation): string =>
+ (entity.updatedBy != null
+ ? entity.updatedBy.fullName
+ ? entity.updatedBy.fullName
+ : entity.updatedBy.username
+ : entity.createdBy != null
+ ? entity.createdBy.fullName
+ ? entity.createdBy.fullName
+ : entity.createdBy.username
+ : '') ?? '';
+
+export const api: ExternalServiceApi = {
+ handshake: handshakeHandler,
+ pushToService: pushToServiceHandler,
+ getIncident: getIncidentHandler,
+ issueTypes: getIssueTypesHandler,
+ fieldsByIssueType: getFieldsByIssueTypeHandler,
+};
diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/config.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/config.ts
deleted file mode 100644
index 54f28e447010a..0000000000000
--- a/x-pack/plugins/actions/server/builtin_action_types/jira/config.ts
+++ /dev/null
@@ -1,14 +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 { ExternalServiceConfiguration } from '../case/types';
-import * as i18n from './translations';
-
-export const config: ExternalServiceConfiguration = {
- id: '.jira',
- name: i18n.NAME,
- minimumLicenseRequired: 'gold',
-};
diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts
index 66be0bad02d7b..d3346557f3684 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts
@@ -4,33 +4,138 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { Logger } from '../../../../../../src/core/server';
-import { createConnector } from '../case/utils';
-import { ActionType } from '../../types';
+import { curry } from 'lodash';
+import { schema } from '@kbn/config-schema';
-import { api } from './api';
-import { config } from './config';
import { validate } from './validators';
-import { createExternalService } from './service';
-import { JiraSecretConfiguration, JiraPublicConfiguration } from './schema';
+import {
+ ExternalIncidentServiceConfiguration,
+ ExternalIncidentServiceSecretConfiguration,
+ ExecutorParamsSchema,
+} from './schema';
import { ActionsConfigurationUtilities } from '../../actions_config';
+import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types';
+import { createExternalService } from './service';
+import { api } from './api';
+import {
+ ExecutorParams,
+ ExecutorSubActionPushParams,
+ JiraPublicConfigurationType,
+ JiraSecretConfigurationType,
+ JiraExecutorResultData,
+ ExecutorSubActionGetFieldsByIssueTypeParams,
+ ExecutorSubActionGetIssueTypesParams,
+} from './types';
+import * as i18n from './translations';
+import { Logger } from '../../../../../../src/core/server';
-export function getActionType({
- logger,
- configurationUtilities,
-}: {
+// TODO: to remove, need to support Case
+import { buildMap, mapParams } from '../case/utils';
+
+interface GetActionTypeParams {
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
-}): ActionType {
- return createConnector({
- api,
- config,
- validate,
- createExternalService,
- validationSchema: {
- config: JiraPublicConfiguration,
- secrets: JiraSecretConfiguration,
+}
+
+const supportedSubActions: string[] = ['pushToService', 'issueTypes', 'fieldsByIssueType'];
+
+// action type definition
+export function getActionType(
+ params: GetActionTypeParams
+): ActionType<
+ JiraPublicConfigurationType,
+ JiraSecretConfigurationType,
+ ExecutorParams,
+ JiraExecutorResultData | {}
+> {
+ const { logger, configurationUtilities } = params;
+ return {
+ id: '.jira',
+ minimumLicenseRequired: 'gold',
+ name: i18n.NAME,
+ validate: {
+ config: schema.object(ExternalIncidentServiceConfiguration, {
+ validate: curry(validate.config)(configurationUtilities),
+ }),
+ secrets: schema.object(ExternalIncidentServiceSecretConfiguration, {
+ validate: curry(validate.secrets)(configurationUtilities),
+ }),
+ params: ExecutorParamsSchema,
+ },
+ executor: curry(executor)({ logger }),
+ };
+}
+
+// action executor
+async function executor(
+ { logger }: { logger: Logger },
+ execOptions: ActionTypeExecutorOptions<
+ JiraPublicConfigurationType,
+ JiraSecretConfigurationType,
+ ExecutorParams
+ >
+): Promise> {
+ const { actionId, config, params, secrets } = execOptions;
+ const { subAction, subActionParams } = params as ExecutorParams;
+ let data: JiraExecutorResultData | null = null;
+
+ const externalService = createExternalService(
+ {
+ config,
+ secrets,
},
logger,
- })({ configurationUtilities });
+ execOptions.proxySettings
+ );
+
+ if (!api[subAction]) {
+ const errorMessage = `[Action][ExternalService] Unsupported subAction type ${subAction}.`;
+ logger.error(errorMessage);
+ throw new Error(errorMessage);
+ }
+
+ if (!supportedSubActions.includes(subAction)) {
+ const errorMessage = `[Action][ExternalService] subAction ${subAction} not implemented.`;
+ logger.error(errorMessage);
+ throw new Error(errorMessage);
+ }
+
+ if (subAction === 'pushToService') {
+ const pushToServiceParams = subActionParams as ExecutorSubActionPushParams;
+
+ const { comments, externalId, ...restParams } = pushToServiceParams;
+ const incidentConfiguration = config.incidentConfiguration;
+ const mapping = incidentConfiguration ? buildMap(incidentConfiguration.mapping) : null;
+ const externalObject =
+ config.incidentConfiguration && mapping
+ ? mapParams(restParams as ExecutorSubActionPushParams, mapping)
+ : {};
+
+ data = await api.pushToService({
+ externalService,
+ mapping,
+ params: { ...pushToServiceParams, externalObject },
+ logger,
+ });
+
+ logger.debug(`response push to service for incident id: ${data.id}`);
+ }
+
+ if (subAction === 'issueTypes') {
+ const getIssueTypesParams = subActionParams as ExecutorSubActionGetIssueTypesParams;
+ data = await api.issueTypes({
+ externalService,
+ params: getIssueTypesParams,
+ });
+ }
+
+ if (subAction === 'fieldsByIssueType') {
+ const getFieldsByIssueTypeParams = subActionParams as ExecutorSubActionGetFieldsByIssueTypeParams;
+ data = await api.fieldsByIssueType({
+ externalService,
+ params: getFieldsByIssueTypeParams,
+ });
+ }
+
+ return { status: 'ok', data: data ?? {}, actionId };
}
diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts
index 709d490a5227f..e7841996fedef 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts
@@ -4,12 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import {
- ExternalService,
- PushToServiceApiParams,
- ExecutorSubActionPushParams,
- MapRecord,
-} from '../case/types';
+import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types';
+
+import { MapRecord } from '../case/common_types';
const createMock = (): jest.Mocked => {
const service = {
@@ -40,6 +37,30 @@ const createMock = (): jest.Mocked => {
})
),
createComment: jest.fn(),
+ findIncidents: jest.fn(),
+ getCapabilities: jest.fn(),
+ getIssueTypes: jest.fn().mockImplementation(() => [
+ {
+ id: '10006',
+ name: 'Task',
+ },
+ {
+ id: '10007',
+ name: 'Bug',
+ },
+ ]),
+ getFieldsByIssueType: jest.fn().mockImplementation(() => ({
+ summary: { allowedValues: [], defaultValue: {} },
+ priority: {
+ allowedValues: [
+ {
+ name: 'Medium',
+ id: '3',
+ },
+ ],
+ defaultValue: { name: 'Medium', id: '3' },
+ },
+ })),
};
service.createComment.mockImplementationOnce(() =>
@@ -96,6 +117,9 @@ const executorParams: ExecutorSubActionPushParams = {
updatedBy: { fullName: 'Elastic User', username: 'elastic' },
title: 'Incident title',
description: 'Incident description',
+ labels: ['kibana', 'elastic'],
+ priority: 'High',
+ issueType: '10006',
comments: [
{
commentId: 'case-comment-1',
@@ -118,7 +142,7 @@ const executorParams: ExecutorSubActionPushParams = {
const apiParams: PushToServiceApiParams = {
...executorParams,
- externalCase: { summary: 'Incident title', description: 'Incident description' },
+ externalObject: { summary: 'Incident title', description: 'Incident description' },
};
export { externalServiceMock, mapping, executorParams, apiParams };
diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts
index 9c831e75d91c1..07c8e22812b27 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts
@@ -5,18 +5,85 @@
*/
import { schema } from '@kbn/config-schema';
-import { ExternalIncidentServiceConfiguration } from '../case/schema';
+import {
+ CommentSchema,
+ EntityInformation,
+ IncidentConfigurationSchema,
+} from '../case/common_schema';
-export const JiraPublicConfiguration = {
+export const ExternalIncidentServiceConfiguration = {
+ apiUrl: schema.string(),
projectKey: schema.string(),
- ...ExternalIncidentServiceConfiguration,
+ // TODO: to remove - set it optional for the current stage to support Case Jira implementation
+ incidentConfiguration: schema.nullable(IncidentConfigurationSchema),
+ isCaseOwned: schema.nullable(schema.boolean()),
};
-export const JiraPublicConfigurationSchema = schema.object(JiraPublicConfiguration);
+export const ExternalIncidentServiceConfigurationSchema = schema.object(
+ ExternalIncidentServiceConfiguration
+);
-export const JiraSecretConfiguration = {
+export const ExternalIncidentServiceSecretConfiguration = {
email: schema.string(),
apiToken: schema.string(),
};
-export const JiraSecretConfigurationSchema = schema.object(JiraSecretConfiguration);
+export const ExternalIncidentServiceSecretConfigurationSchema = schema.object(
+ ExternalIncidentServiceSecretConfiguration
+);
+
+export const ExecutorSubActionSchema = schema.oneOf([
+ schema.literal('getIncident'),
+ schema.literal('pushToService'),
+ schema.literal('handshake'),
+ schema.literal('issueTypes'),
+ schema.literal('fieldsByIssueType'),
+]);
+
+export const ExecutorSubActionPushParamsSchema = schema.object({
+ savedObjectId: schema.string(),
+ title: schema.string(),
+ description: schema.nullable(schema.string()),
+ externalId: schema.nullable(schema.string()),
+ issueType: schema.nullable(schema.string()),
+ priority: schema.nullable(schema.string()),
+ labels: schema.nullable(schema.arrayOf(schema.string())),
+ // TODO: modify later to string[] - need for support Case schema
+ comments: schema.nullable(schema.arrayOf(CommentSchema)),
+ ...EntityInformation,
+});
+
+export const ExecutorSubActionGetIncidentParamsSchema = schema.object({
+ externalId: schema.string(),
+});
+
+// Reserved for future implementation
+export const ExecutorSubActionHandshakeParamsSchema = schema.object({});
+export const ExecutorSubActionGetCapabilitiesParamsSchema = schema.object({});
+export const ExecutorSubActionGetIssueTypesParamsSchema = schema.object({});
+export const ExecutorSubActionGetFieldsByIssueTypeParamsSchema = schema.object({
+ id: schema.string(),
+});
+
+export const ExecutorParamsSchema = schema.oneOf([
+ schema.object({
+ subAction: schema.literal('getIncident'),
+ subActionParams: ExecutorSubActionGetIncidentParamsSchema,
+ }),
+ schema.object({
+ subAction: schema.literal('handshake'),
+ subActionParams: ExecutorSubActionHandshakeParamsSchema,
+ }),
+ schema.object({
+ subAction: schema.literal('pushToService'),
+ subActionParams: ExecutorSubActionPushParamsSchema,
+ }),
+ schema.object({
+ subAction: schema.literal('issueTypes'),
+ subActionParams: ExecutorSubActionGetIssueTypesParamsSchema,
+ }),
+ schema.object({
+ subAction: schema.literal('fieldsByIssueType'),
+ subActionParams: ExecutorSubActionGetFieldsByIssueTypeParamsSchema,
+ }),
+]);
diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts
index 547595b4c183f..2439c507c3328 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts
@@ -8,11 +8,15 @@ import axios from 'axios';
import { createExternalService } from './service';
import * as utils from '../lib/axios_utils';
-import { ExternalService } from '../case/types';
+import { ExternalService } from './types';
import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
const logger = loggingSystemMock.create().get() as jest.Mocked;
+interface ResponseError extends Error {
+ response?: { data: { errors: Record } };
+}
+
jest.mock('axios');
jest.mock('../lib/axios_utils', () => {
const originalUtils = jest.requireActual('../lib/axios_utils');
@@ -25,6 +29,72 @@ jest.mock('../lib/axios_utils', () => {
axios.create = jest.fn(() => axios);
const requestMock = utils.request as jest.Mock;
+const issueTypesResponse = {
+ data: {
+ projects: [
+ {
+ issuetypes: [
+ {
+ id: '10006',
+ name: 'Task',
+ },
+ {
+ id: '10007',
+ name: 'Bug',
+ },
+ ],
+ },
+ ],
+ },
+};
+
+const fieldsResponse = {
+ data: {
+ projects: [
+ {
+ issuetypes: [
+ {
+ id: '10006',
+ name: 'Task',
+ fields: {
+ summary: { fieldId: 'summary' },
+ priority: {
+ fieldId: 'priority',
+ allowedValues: [
+ {
+ name: 'Highest',
+ id: '1',
+ },
+ {
+ name: 'High',
+ id: '2',
+ },
+ {
+ name: 'Medium',
+ id: '3',
+ },
+ {
+ name: 'Low',
+ id: '4',
+ },
+ {
+ name: 'Lowest',
+ id: '5',
+ },
+ ],
+ defaultValue: {
+ name: 'Medium',
+ id: '3',
+ },
+ },
+ },
+ },
+ ],
+ },
+ ],
+ },
+};
+
describe('Jira service', () => {
let service: ExternalService;
@@ -116,19 +186,24 @@ describe('Jira service', () => {
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
- throw new Error('An error has occurred');
+ const error: ResponseError = new Error('An error has occurred');
+ error.response = { data: { errors: { summary: 'Required field' } } };
+ throw error;
});
expect(service.getIncident('1')).rejects.toThrow(
- 'Unable to get incident with id 1. Error: An error has occurred'
+ '[Action][Jira]: Unable to get incident with id 1. Error: An error has occurred Reason: Required field'
);
});
});
describe('createIncident', () => {
test('it creates the incident correctly', async () => {
- // The response from Jira when creating an issue contains only the key and the id.
- // The service makes two calls when creating an issue. One to create and one to get
- // the created incident with all the necessary fields.
+ /* The response from Jira when creating an issue contains only the key and the id.
+ The function makes the following calls when creating an issue:
+ 1. Get issueTypes to set a default ONLY when incident.issueType is missing
+ 2. Create the issue.
+ 3. Get the created issue with all the necessary fields.
+ */
requestMock.mockImplementationOnce(() => ({
data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } },
}));
@@ -138,7 +213,13 @@ describe('Jira service', () => {
}));
const res = await service.createIncident({
- incident: { summary: 'title', description: 'desc' },
+ incident: {
+ summary: 'title',
+ description: 'desc',
+ labels: [],
+ issueType: '10006',
+ priority: 'High',
+ },
});
expect(res).toEqual({
@@ -149,6 +230,68 @@ describe('Jira service', () => {
});
});
+ test('it creates the incident correctly without issue type', async () => {
+ /* The response from Jira when creating an issue contains only the key and the id.
+ The function makes the following calls when creating an issue:
+ 1. Get issueTypes to set a default ONLY when incident.issueType is missing
+ 2. Create the issue.
+ 3. Get the created issue with all the necessary fields.
+ */
+ // getIssueType mocks
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ capabilities: {
+ navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation',
+ },
+ },
+ }));
+
+ // getIssueType mocks
+ requestMock.mockImplementationOnce(() => issueTypesResponse);
+
+ requestMock.mockImplementationOnce(() => ({
+ data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } },
+ }));
+
+ requestMock.mockImplementationOnce(() => ({
+ data: { id: '1', key: 'CK-1', fields: { created: '2020-04-27T10:59:46.202Z' } },
+ }));
+
+ const res = await service.createIncident({
+ incident: {
+ summary: 'title',
+ description: 'desc',
+ labels: [],
+ priority: 'High',
+ issueType: null,
+ },
+ });
+
+ expect(res).toEqual({
+ title: 'CK-1',
+ id: '1',
+ pushedDate: '2020-04-27T10:59:46.202Z',
+ url: 'https://siem-kibana.atlassian.net/browse/CK-1',
+ });
+
+ expect(requestMock).toHaveBeenCalledWith({
+ axios,
+ url: 'https://siem-kibana.atlassian.net/rest/api/2/issue',
+ logger,
+ method: 'post',
+ data: {
+ fields: {
+ summary: 'title',
+ description: 'desc',
+ project: { key: 'CK' },
+ issuetype: { id: '10006' },
+ labels: [],
+ priority: { name: 'High' },
+ },
+ },
+ });
+ });
+
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() => ({
data: {
@@ -159,7 +302,13 @@ describe('Jira service', () => {
}));
await service.createIncident({
- incident: { summary: 'title', description: 'desc' },
+ incident: {
+ summary: 'title',
+ description: 'desc',
+ labels: [],
+ issueType: '10006',
+ priority: 'High',
+ },
});
expect(requestMock).toHaveBeenCalledWith({
@@ -172,7 +321,9 @@ describe('Jira service', () => {
summary: 'title',
description: 'desc',
project: { key: 'CK' },
- issuetype: { name: 'Task' },
+ issuetype: { id: '10006' },
+ labels: [],
+ priority: { name: 'High' },
},
},
});
@@ -180,14 +331,24 @@ describe('Jira service', () => {
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
- throw new Error('An error has occurred');
+ const error: ResponseError = new Error('An error has occurred');
+ error.response = { data: { errors: { summary: 'Required field' } } };
+ throw error;
});
expect(
service.createIncident({
- incident: { summary: 'title', description: 'desc' },
+ incident: {
+ summary: 'title',
+ description: 'desc',
+ labels: [],
+ issueType: '10006',
+ priority: 'High',
+ },
})
- ).rejects.toThrow('[Action][Jira]: Unable to create incident. Error: An error has occurred');
+ ).rejects.toThrow(
+ '[Action][Jira]: Unable to create incident. Error: An error has occurred. Reason: Required field'
+ );
});
});
@@ -203,7 +364,13 @@ describe('Jira service', () => {
const res = await service.updateIncident({
incidentId: '1',
- incident: { summary: 'title', description: 'desc' },
+ incident: {
+ summary: 'title',
+ description: 'desc',
+ labels: [],
+ issueType: '10006',
+ priority: 'High',
+ },
});
expect(res).toEqual({
@@ -225,7 +392,13 @@ describe('Jira service', () => {
await service.updateIncident({
incidentId: '1',
- incident: { summary: 'title', description: 'desc' },
+ incident: {
+ summary: 'title',
+ description: 'desc',
+ labels: [],
+ issueType: '10006',
+ priority: 'High',
+ },
});
expect(requestMock).toHaveBeenCalledWith({
@@ -233,22 +406,39 @@ describe('Jira service', () => {
logger,
method: 'put',
url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1',
- data: { fields: { summary: 'title', description: 'desc' } },
+ data: {
+ fields: {
+ summary: 'title',
+ description: 'desc',
+ labels: [],
+ priority: { name: 'High' },
+ issuetype: { id: '10006' },
+ project: { key: 'CK' },
+ },
+ },
});
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
- throw new Error('An error has occurred');
+ const error: ResponseError = new Error('An error has occurred');
+ error.response = { data: { errors: { summary: 'Required field' } } };
+ throw error;
});
expect(
service.updateIncident({
incidentId: '1',
- incident: { summary: 'title', description: 'desc' },
+ incident: {
+ summary: 'title',
+ description: 'desc',
+ labels: [],
+ issueType: '10006',
+ priority: 'High',
+ },
})
).rejects.toThrow(
- '[Action][Jira]: Unable to update incident with id 1. Error: An error has occurred'
+ '[Action][Jira]: Unable to update incident with id 1. Error: An error has occurred. Reason: Required field'
);
});
});
@@ -265,8 +455,14 @@ describe('Jira service', () => {
const res = await service.createComment({
incidentId: '1',
- comment: { comment: 'comment', commentId: 'comment-1' },
- field: 'comments',
+ comment: {
+ comment: 'comment',
+ commentId: 'comment-1',
+ createdBy: null,
+ createdAt: null,
+ updatedAt: null,
+ updatedBy: null,
+ },
});
expect(res).toEqual({
@@ -287,8 +483,14 @@ describe('Jira service', () => {
await service.createComment({
incidentId: '1',
- comment: { comment: 'comment', commentId: 'comment-1' },
- field: 'my_field',
+ comment: {
+ comment: 'comment',
+ commentId: 'comment-1',
+ createdBy: null,
+ createdAt: null,
+ updatedAt: null,
+ updatedBy: null,
+ },
});
expect(requestMock).toHaveBeenCalledWith({
@@ -302,18 +504,416 @@ describe('Jira service', () => {
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
- throw new Error('An error has occurred');
+ const error: ResponseError = new Error('An error has occurred');
+ error.response = { data: { errors: { summary: 'Required field' } } };
+ throw error;
});
expect(
service.createComment({
incidentId: '1',
- comment: { comment: 'comment', commentId: 'comment-1' },
- field: 'comments',
+ comment: {
+ comment: 'comment',
+ commentId: 'comment-1',
+ createdBy: null,
+ createdAt: null,
+ updatedAt: null,
+ updatedBy: null,
+ },
})
).rejects.toThrow(
- '[Action][Jira]: Unable to create comment at incident with id 1. Error: An error has occurred'
+ '[Action][Jira]: Unable to create comment at incident with id 1. Error: An error has occurred. Reason: Required field'
+ );
+ });
+ });
+
+ describe('getCapabilities', () => {
+ test('it should return the capabilities', async () => {
+ requestMock.mockImplementation(() => ({
+ data: {
+ capabilities: {
+ navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation',
+ },
+ },
+ }));
+ const res = await service.getCapabilities();
+ expect(res).toEqual({
+ capabilities: {
+ navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation',
+ },
+ });
+ });
+
+ test('it should call request with correct arguments', async () => {
+ requestMock.mockImplementation(() => ({
+ data: {
+ capabilities: {
+ navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation',
+ },
+ },
+ }));
+
+ await service.getCapabilities();
+
+ expect(requestMock).toHaveBeenCalledWith({
+ axios,
+ logger,
+ method: 'get',
+ url: 'https://siem-kibana.atlassian.net/rest/capabilities',
+ });
+ });
+
+ test('it should throw an error', async () => {
+ requestMock.mockImplementation(() => {
+ const error: ResponseError = new Error('An error has occurred');
+ error.response = { data: { errors: { capabilities: 'Could not get capabilities' } } };
+ throw error;
+ });
+
+ expect(service.getCapabilities()).rejects.toThrow(
+ '[Action][Jira]: Unable to get capabilities. Error: An error has occurred. Reason: Could not get capabilities'
);
});
});
+
+ describe('getIssueTypes', () => {
+ describe('Old API', () => {
+ test('it should return the issue types', async () => {
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ capabilities: {
+ navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation',
+ },
+ },
+ }));
+
+ requestMock.mockImplementationOnce(() => issueTypesResponse);
+
+ const res = await service.getIssueTypes();
+
+ expect(res).toEqual([
+ {
+ id: '10006',
+ name: 'Task',
+ },
+ {
+ id: '10007',
+ name: 'Bug',
+ },
+ ]);
+ });
+
+ test('it should call request with correct arguments', async () => {
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ capabilities: {
+ navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation',
+ },
+ },
+ }));
+
+ requestMock.mockImplementationOnce(() => issueTypesResponse);
+
+ await service.getIssueTypes();
+
+ expect(requestMock).toHaveBeenLastCalledWith({
+ axios,
+ logger,
+ method: 'get',
+ url:
+ 'https://siem-kibana.atlassian.net/rest/api/2/issue/createmeta?projectKeys=CK&expand=projects.issuetypes.fields',
+ });
+ });
+
+ test('it should throw an error', async () => {
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ capabilities: {
+ navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation',
+ },
+ },
+ }));
+
+ requestMock.mockImplementation(() => {
+ const error: ResponseError = new Error('An error has occurred');
+ error.response = { data: { errors: { issuetypes: 'Could not get issue types' } } };
+ throw error;
+ });
+
+ expect(service.getIssueTypes()).rejects.toThrow(
+ '[Action][Jira]: Unable to get issue types. Error: An error has occurred. Reason: Could not get issue types'
+ );
+ });
+ });
+ describe('New API', () => {
+ test('it should return the issue types', async () => {
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ capabilities: {
+ 'list-project-issuetypes':
+ 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes',
+ 'list-issuetype-fields':
+ 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields',
+ },
+ },
+ }));
+
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ values: issueTypesResponse.data.projects[0].issuetypes,
+ },
+ }));
+
+ const res = await service.getIssueTypes();
+
+ expect(res).toEqual([
+ {
+ id: '10006',
+ name: 'Task',
+ },
+ {
+ id: '10007',
+ name: 'Bug',
+ },
+ ]);
+ });
+
+ test('it should call request with correct arguments', async () => {
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ capabilities: {
+ 'list-project-issuetypes':
+ 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes',
+ 'list-issuetype-fields':
+ 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields',
+ },
+ },
+ }));
+
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ values: issueTypesResponse.data.projects[0].issuetypes,
+ },
+ }));
+
+ await service.getIssueTypes();
+
+ expect(requestMock).toHaveBeenLastCalledWith({
+ axios,
+ logger,
+ method: 'get',
+ url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/createmeta/CK/issuetypes',
+ });
+ });
+
+ test('it should throw an error', async () => {
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ capabilities: {
+ 'list-project-issuetypes':
+ 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes',
+ 'list-issuetype-fields':
+ 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields',
+ },
+ },
+ }));
+
+ requestMock.mockImplementation(() => {
+ const error: ResponseError = new Error('An error has occurred');
+ error.response = { data: { errors: { issuetypes: 'Could not get issue types' } } };
+ throw error;
+ });
+
+ expect(service.getIssueTypes()).rejects.toThrow(
+ '[Action][Jira]: Unable to get issue types. Error: An error has occurred. Reason: Could not get issue types'
+ );
+ });
+ });
+ });
+
+ describe('getFieldsByIssueType', () => {
+ describe('Old API', () => {
+ test('it should return the fields', async () => {
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ capabilities: {
+ navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation',
+ },
+ },
+ }));
+
+ requestMock.mockImplementationOnce(() => fieldsResponse);
+
+ const res = await service.getFieldsByIssueType('10006');
+
+ expect(res).toEqual({
+ priority: {
+ allowedValues: [
+ { id: '1', name: 'Highest' },
+ { id: '2', name: 'High' },
+ { id: '3', name: 'Medium' },
+ { id: '4', name: 'Low' },
+ { id: '5', name: 'Lowest' },
+ ],
+ defaultValue: { id: '3', name: 'Medium' },
+ },
+ summary: { allowedValues: [], defaultValue: {} },
+ });
+ });
+
+ test('it should call request with correct arguments', async () => {
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ capabilities: {
+ navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation',
+ },
+ },
+ }));
+
+ requestMock.mockImplementationOnce(() => fieldsResponse);
+
+ await service.getFieldsByIssueType('10006');
+
+ expect(requestMock).toHaveBeenLastCalledWith({
+ axios,
+ logger,
+ method: 'get',
+ url:
+ 'https://siem-kibana.atlassian.net/rest/api/2/issue/createmeta?projectKeys=CK&issuetypeIds=10006&expand=projects.issuetypes.fields',
+ });
+ });
+
+ test('it should throw an error', async () => {
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ capabilities: {
+ navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation',
+ },
+ },
+ }));
+
+ requestMock.mockImplementation(() => {
+ const error: ResponseError = new Error('An error has occurred');
+ error.response = { data: { errors: { fields: 'Could not get fields' } } };
+ throw error;
+ });
+
+ expect(service.getFieldsByIssueType('10006')).rejects.toThrow(
+ '[Action][Jira]: Unable to get fields. Error: An error has occurred. Reason: Could not get fields'
+ );
+ });
+ });
+
+ describe('New API', () => {
+ test('it should return the fields', async () => {
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ capabilities: {
+ 'list-project-issuetypes':
+ 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes',
+ 'list-issuetype-fields':
+ 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields',
+ },
+ },
+ }));
+
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ values: [
+ { fieldId: 'summary' },
+ {
+ fieldId: 'priority',
+ allowedValues: [
+ {
+ name: 'Medium',
+ id: '3',
+ },
+ ],
+ defaultValue: {
+ name: 'Medium',
+ id: '3',
+ },
+ },
+ ],
+ },
+ }));
+
+ const res = await service.getFieldsByIssueType('10006');
+
+ expect(res).toEqual({
+ priority: {
+ allowedValues: [{ id: '3', name: 'Medium' }],
+ defaultValue: { id: '3', name: 'Medium' },
+ },
+ summary: { allowedValues: [], defaultValue: {} },
+ });
+ });
+
+ test('it should call request with correct arguments', async () => {
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ capabilities: {
+ 'list-project-issuetypes':
+ 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes',
+ 'list-issuetype-fields':
+ 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields',
+ },
+ },
+ }));
+
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ values: [
+ { fieldId: 'summary' },
+ {
+ fieldId: 'priority',
+ allowedValues: [
+ {
+ name: 'Medium',
+ id: '3',
+ },
+ ],
+ defaultValue: {
+ name: 'Medium',
+ id: '3',
+ },
+ },
+ ],
+ },
+ }));
+
+ await service.getFieldsByIssueType('10006');
+
+ expect(requestMock).toHaveBeenLastCalledWith({
+ axios,
+ logger,
+ method: 'get',
+ url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/createmeta/CK/issuetypes/10006',
+ });
+ });
+
+ test('it should throw an error', async () => {
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ capabilities: {
+ 'list-project-issuetypes':
+ 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes',
+ 'list-issuetype-fields':
+ 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields',
+ },
+ },
+ }));
+
+ requestMock.mockImplementation(() => {
+ const error: ResponseError = new Error('An error has occurred');
+ error.response = { data: { errors: { issuetypes: 'Could not get issue types' } } };
+ throw error;
+ });
+
+ expect(service.getFieldsByIssueType('10006')).rejects.toThrow(
+ '[Action][Jira]: Unable to get fields. Error: An error has occurred. Reason: Could not get issue types'
+ );
+ });
+ });
+ });
});
diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts
index aec73cfb375ed..84b6e70d2a100 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts
@@ -6,14 +6,20 @@
import axios from 'axios';
-import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from '../case/types';
import { Logger } from '../../../../../../src/core/server';
import {
+ ExternalServiceCredentials,
+ ExternalService,
+ CreateIncidentParams,
+ UpdateIncidentParams,
JiraPublicConfigurationType,
JiraSecretConfigurationType,
- CreateIncidentRequest,
- UpdateIncidentRequest,
- CreateCommentRequest,
+ Fields,
+ CreateCommentParams,
+ Incident,
+ ResponseError,
+ ExternalServiceCommentResponse,
+ ExternalServiceIncidentResponse,
} from './types';
import * as i18n from './translations';
@@ -22,11 +28,12 @@ import { ProxySettings } from '../../types';
const VERSION = '2';
const BASE_URL = `rest/api/${VERSION}`;
-const INCIDENT_URL = `issue`;
-const COMMENT_URL = `comment`;
+const CAPABILITIES_URL = `rest/capabilities`;
const VIEW_INCIDENT_URL = `browse`;
+const createMetaCapabilities = ['list-project-issuetypes', 'list-issuetype-fields'];
+
export const createExternalService = (
{ config, secrets }: ExternalServiceCredentials,
logger: Logger,
@@ -39,8 +46,13 @@ export const createExternalService = (
throw Error(`[Action]${i18n.NAME}: Wrong configuration.`);
}
- const incidentUrl = `${url}/${BASE_URL}/${INCIDENT_URL}`;
- const commentUrl = `${incidentUrl}/{issueId}/${COMMENT_URL}`;
+ const incidentUrl = `${url}/${BASE_URL}/issue`;
+ const capabilitiesUrl = `${url}/${CAPABILITIES_URL}`;
+ const commentUrl = `${incidentUrl}/{issueId}/comment`;
+ const getIssueTypesOldAPIURL = `${url}/${BASE_URL}/issue/createmeta?projectKeys=${projectKey}&expand=projects.issuetypes.fields`;
+ const getIssueTypeFieldsOldAPIURL = `${url}/${BASE_URL}/issue/createmeta?projectKeys=${projectKey}&issuetypeIds={issueTypeId}&expand=projects.issuetypes.fields`;
+ const getIssueTypesUrl = `${url}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes`;
+ const getIssueTypeFieldsUrl = `${url}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes/{issueTypeId}`;
const axiosInstance = axios.create({
auth: { username: email, password: apiToken },
});
@@ -52,6 +64,60 @@ export const createExternalService = (
const getCommentsURL = (issueId: string) => {
return commentUrl.replace('{issueId}', issueId);
};
+ const createGetIssueTypeFieldsUrl = (uri: string, issueTypeId: string) => {
+ return uri.replace('{issueTypeId}', issueTypeId);
+ };
+
+ const createFields = (key: string, incident: Incident): Fields => {
+ let fields: Fields = {
+ summary: incident.summary,
+ project: { key },
+ };
+
+ if (incident.issueType) {
+ fields = { ...fields, issuetype: { id: incident.issueType } };
+ }
+
+ if (incident.description) {
+ fields = { ...fields, description: incident.description };
+ }
+
+ if (incident.labels) {
+ fields = { ...fields, labels: incident.labels };
+ }
+
+ if (incident.priority) {
+ fields = { ...fields, priority: { name: incident.priority } };
+ }
+
+ return fields;
+ };
+
+ const createErrorMessage = (errors: ResponseError) => {
+ return Object.entries(errors).reduce((errorMessage, [, value]) => {
+ const msg = errorMessage.length > 0 ? `${errorMessage} ${value}` : value;
+ return msg;
+ }, '');
+ };
+
+ const hasSupportForNewAPI = (capabilities: { capabilities?: {} }) =>
+ createMetaCapabilities.every((c) => Object.keys(capabilities?.capabilities ?? {}).includes(c));
+
+ const normalizeIssueTypes = (issueTypes: Array<{ id: string; name: string }>) =>
+ issueTypes.map((type) => ({ id: type.id, name: type.name }));
+
+ const normalizeFields = (fields: {
+ [key: string]: { allowedValues?: Array<{}>; defaultValue?: {} };
+ }) =>
+ Object.keys(fields ?? {}).reduce((fieldsAcc, fieldKey) => {
+ return {
+ ...fieldsAcc,
+ [fieldKey]: {
+ allowedValues: fields[fieldKey]?.allowedValues ?? [],
+ defaultValue: fields[fieldKey]?.defaultValue ?? {},
+ },
+ };
+ }, {});
const getIncident = async (id: string) => {
try {
@@ -67,23 +133,46 @@ export const createExternalService = (
return { ...rest, ...fields };
} catch (error) {
throw new Error(
- getErrorMessage(i18n.NAME, `Unable to get incident with id ${id}. Error: ${error.message}`)
+ getErrorMessage(
+ i18n.NAME,
+ `Unable to get incident with id ${id}. Error: ${
+ error.message
+ } Reason: ${createErrorMessage(error.response?.data?.errors ?? {})}`
+ )
);
}
};
- const createIncident = async ({ incident }: ExternalServiceParams) => {
- // The response from Jira when creating an issue contains only the key and the id.
- // The function makes two calls when creating an issue. One to create the issue and one to get
- // the created issue with all the necessary fields.
+ const createIncident = async ({
+ incident,
+ }: CreateIncidentParams): Promise => {
+ /* The response from Jira when creating an issue contains only the key and the id.
+ The function makes the following calls when creating an issue:
+ 1. Get issueTypes to set a default ONLY when incident.issueType is missing
+ 2. Create the issue.
+ 3. Get the created issue with all the necessary fields.
+ */
+
+ let issueType = incident.issueType;
+
+ if (!incident.issueType) {
+ const issueTypes = await getIssueTypes();
+ issueType = issueTypes[0]?.id ?? '';
+ }
+
+ const fields = createFields(projectKey, {
+ ...incident,
+ issueType,
+ });
+
try {
- const res = await request({
+ const res = await request({
axios: axiosInstance,
url: `${incidentUrl}`,
logger,
method: 'post',
data: {
- fields: { ...incident, project: { key: projectKey }, issuetype: { name: 'Task' } },
+ fields,
},
proxySettings,
});
@@ -98,23 +187,38 @@ export const createExternalService = (
};
} catch (error) {
throw new Error(
- getErrorMessage(i18n.NAME, `Unable to create incident. Error: ${error.message}`)
+ getErrorMessage(
+ i18n.NAME,
+ `Unable to create incident. Error: ${error.message}. Reason: ${createErrorMessage(
+ error.response?.data?.errors ?? {}
+ )}`
+ )
);
}
};
- const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => {
+ const updateIncident = async ({
+ incidentId,
+ incident,
+ }: UpdateIncidentParams): Promise => {
+ const incidentWithoutNullValues = Object.entries(incident).reduce(
+ (obj, [key, value]) => (value != null ? { ...obj, [key]: value } : obj),
+ {} as Incident
+ );
+
+ const fields = createFields(projectKey, incidentWithoutNullValues);
+
try {
- await request({
+ await request({
axios: axiosInstance,
method: 'put',
url: `${incidentUrl}/${incidentId}`,
logger,
- data: { fields: { ...incident } },
+ data: { fields },
proxySettings,
});
- const updatedIncident = await getIncident(incidentId);
+ const updatedIncident = await getIncident(incidentId as string);
return {
title: updatedIncident.key,
@@ -126,15 +230,20 @@ export const createExternalService = (
throw new Error(
getErrorMessage(
i18n.NAME,
- `Unable to update incident with id ${incidentId}. Error: ${error.message}`
+ `Unable to update incident with id ${incidentId}. Error: ${
+ error.message
+ }. Reason: ${createErrorMessage(error.response?.data?.errors ?? {})}`
)
);
}
};
- const createComment = async ({ incidentId, comment, field }: ExternalServiceParams) => {
+ const createComment = async ({
+ incidentId,
+ comment,
+ }: CreateCommentParams): Promise => {
try {
- const res = await request({
+ const res = await request({
axios: axiosInstance,
method: 'post',
url: getCommentsURL(incidentId),
@@ -152,7 +261,118 @@ export const createExternalService = (
throw new Error(
getErrorMessage(
i18n.NAME,
- `Unable to create comment at incident with id ${incidentId}. Error: ${error.message}`
+ `Unable to create comment at incident with id ${incidentId}. Error: ${
+ error.message
+ }. Reason: ${createErrorMessage(error.response?.data?.errors ?? {})}`
+ )
+ );
+ }
+ };
+
+ const getCapabilities = async () => {
+ try {
+ const res = await request({
+ axios: axiosInstance,
+ method: 'get',
+ url: capabilitiesUrl,
+ logger,
+ proxySettings,
+ });
+
+ return { ...res.data };
+ } catch (error) {
+ throw new Error(
+ getErrorMessage(
+ i18n.NAME,
+ `Unable to get capabilities. Error: ${error.message}. Reason: ${createErrorMessage(
+ error.response?.data?.errors ?? {}
+ )}`
+ )
+ );
+ }
+ };
+
+ const getIssueTypes = async () => {
+ const capabilitiesResponse = await getCapabilities();
+ const supportsNewAPI = hasSupportForNewAPI(capabilitiesResponse);
+
+ try {
+ if (!supportsNewAPI) {
+ const res = await request({
+ axios: axiosInstance,
+ method: 'get',
+ url: getIssueTypesOldAPIURL,
+ logger,
+ proxySettings,
+ });
+
+ const issueTypes = res.data.projects[0]?.issuetypes ?? [];
+ return normalizeIssueTypes(issueTypes);
+ } else {
+ const res = await request({
+ axios: axiosInstance,
+ method: 'get',
+ url: getIssueTypesUrl,
+ logger,
+ proxySettings,
+ });
+
+ const issueTypes = res.data.values;
+ return normalizeIssueTypes(issueTypes);
+ }
+ } catch (error) {
+ throw new Error(
+ getErrorMessage(
+ i18n.NAME,
+ `Unable to get issue types. Error: ${error.message}. Reason: ${createErrorMessage(
+ error.response?.data?.errors ?? {}
+ )}`
+ )
+ );
+ }
+ };
+
+ const getFieldsByIssueType = async (issueTypeId: string) => {
+ const capabilitiesResponse = await getCapabilities();
+ const supportsNewAPI = hasSupportForNewAPI(capabilitiesResponse);
+
+ try {
+ if (!supportsNewAPI) {
+ const res = await request({
+ axios: axiosInstance,
+ method: 'get',
+ url: createGetIssueTypeFieldsUrl(getIssueTypeFieldsOldAPIURL, issueTypeId),
+ logger,
+ proxySettings,
+ });
+
+ const fields = res.data.projects[0]?.issuetypes[0]?.fields || {};
+ return normalizeFields(fields);
+ } else {
+ const res = await request({
+ axios: axiosInstance,
+ method: 'get',
+ url: createGetIssueTypeFieldsUrl(getIssueTypeFieldsUrl, issueTypeId),
+ logger,
+ proxySettings,
+ });
+
+ const fields = res.data.values.reduce(
+ (acc: { [x: string]: {} }, value: { fieldId: string }) => ({
+ ...acc,
+ [value.fieldId]: { ...value },
+ }),
+ {}
+ );
+ return normalizeFields(fields);
+ }
+ } catch (error) {
+ throw new Error(
+ getErrorMessage(
+ i18n.NAME,
+ `Unable to get fields. Error: ${error.message}. Reason: ${createErrorMessage(
+ error.response?.data?.errors ?? {}
+ )}`
)
);
}
@@ -163,5 +383,8 @@ export const createExternalService = (
createIncident,
updateIncident,
createComment,
+ getCapabilities,
+ getIssueTypes,
+ getFieldsByIssueType,
};
};
diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/translations.ts
index dae0d75952e11..0e71de813eb5d 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/jira/translations.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/jira/translations.ts
@@ -9,3 +9,19 @@ import { i18n } from '@kbn/i18n';
export const NAME = i18n.translate('xpack.actions.builtin.case.jiraTitle', {
defaultMessage: 'Jira',
});
+
+export const ALLOWED_HOSTS_ERROR = (message: string) =>
+ i18n.translate('xpack.actions.builtin.jira.configuration.apiAllowedHostsError', {
+ defaultMessage: 'error configuring connector action: {message}',
+ values: {
+ message,
+ },
+ });
+
+// TODO: remove when Case mappings will be removed
+export const MAPPING_EMPTY = i18n.translate(
+ 'xpack.actions.builtin.jira.configuration.emptyMapping',
+ {
+ defaultMessage: '[incidentConfiguration.mapping]: expected non-empty but got empty',
+ }
+);
diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts
index 8d9c6b92abb3b..5e97f5309f8ee 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts
@@ -4,29 +4,169 @@
* you may not use this file except in compliance with the Elastic License.
*/
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
import { TypeOf } from '@kbn/config-schema';
-import { JiraPublicConfigurationSchema, JiraSecretConfigurationSchema } from './schema';
+import {
+ ExternalIncidentServiceConfigurationSchema,
+ ExternalIncidentServiceSecretConfigurationSchema,
+ ExecutorParamsSchema,
+ ExecutorSubActionPushParamsSchema,
+ ExecutorSubActionGetIncidentParamsSchema,
+ ExecutorSubActionHandshakeParamsSchema,
+ ExecutorSubActionGetCapabilitiesParamsSchema,
+ ExecutorSubActionGetIssueTypesParamsSchema,
+ ExecutorSubActionGetFieldsByIssueTypeParamsSchema,
+} from './schema';
+import { ActionsConfigurationUtilities } from '../../actions_config';
+import { IncidentConfigurationSchema } from '../case/common_schema';
+import { Comment } from '../case/common_types';
+import { Logger } from '../../../../../../src/core/server';
+
+export type JiraPublicConfigurationType = TypeOf;
+export type JiraSecretConfigurationType = TypeOf<
+ typeof ExternalIncidentServiceSecretConfigurationSchema
+>;
+
+export type ExecutorParams = TypeOf;
+export type ExecutorSubActionPushParams = TypeOf;
+
+export type IncidentConfiguration = TypeOf;
+
+export interface ExternalServiceCredentials {
+ config: Record;
+ secrets: Record;
+}
+
+export interface ExternalServiceValidation {
+ config: (configurationUtilities: ActionsConfigurationUtilities, configObject: any) => void;
+ secrets: (configurationUtilities: ActionsConfigurationUtilities, secrets: any) => void;
+}
+
+export interface ExternalServiceIncidentResponse {
+ id: string;
+ title: string;
+ url: string;
+ pushedDate: string;
+}
+
+export interface ExternalServiceCommentResponse {
+ commentId: string;
+ pushedDate: string;
+ externalCommentId?: string;
+}
+
+export type ExternalServiceParams = Record;
+
+export type Incident = Pick<
+ ExecutorSubActionPushParams,
+ 'description' | 'priority' | 'labels' | 'issueType'
+> & { summary: string };
+
+export interface CreateIncidentParams {
+ incident: Incident;
+}
+
+export interface UpdateIncidentParams {
+ incidentId: string;
+ incident: Incident;
+}
+
+export interface CreateCommentParams {
+ incidentId: string;
+ comment: Comment;
+}
-export type JiraPublicConfigurationType = TypeOf;
-export type JiraSecretConfigurationType = TypeOf;
+export type GetIssueTypesResponse = Array<{ id: string; name: string }>;
+export type GetFieldsByIssueTypeResponse = Record<
+ string,
+ { allowedValues: Array<{}>; defaultValue: {} }
+>;
-interface CreateIncidentBasicRequestArgs {
- summary: string;
- description: string;
+export interface ExternalService {
+ getIncident: (id: string) => Promise;
+ createIncident: (params: CreateIncidentParams) => Promise;
+ updateIncident: (params: UpdateIncidentParams) => Promise;
+ createComment: (params: CreateCommentParams) => Promise;
+ getCapabilities: () => Promise;
+ getIssueTypes: () => Promise;
+ getFieldsByIssueType: (issueTypeId: string) => Promise;
}
-interface CreateIncidentRequestArgs extends CreateIncidentBasicRequestArgs {
- project: { key: string };
- issuetype: { name: string };
+
+export interface PushToServiceApiParams extends ExecutorSubActionPushParams {
+ externalObject: Record;
+}
+
+export type ExecutorSubActionGetIncidentParams = TypeOf<
+ typeof ExecutorSubActionGetIncidentParamsSchema
+>;
+
+export type ExecutorSubActionHandshakeParams = TypeOf<
+ typeof ExecutorSubActionHandshakeParamsSchema
+>;
+
+export type ExecutorSubActionGetCapabilitiesParams = TypeOf<
+ typeof ExecutorSubActionGetCapabilitiesParamsSchema
+>;
+
+export type ExecutorSubActionGetIssueTypesParams = TypeOf<
+ typeof ExecutorSubActionGetIssueTypesParamsSchema
+>;
+
+export type ExecutorSubActionGetFieldsByIssueTypeParams = TypeOf<
+ typeof ExecutorSubActionGetFieldsByIssueTypeParamsSchema
+>;
+
+export interface ExternalServiceApiHandlerArgs {
+ externalService: ExternalService;
+ mapping: Map | null;
+}
+
+export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs {
+ params: PushToServiceApiParams;
+ logger: Logger;
}
-export interface CreateIncidentRequest {
- fields: CreateIncidentRequestArgs;
+export interface GetIncidentApiHandlerArgs extends ExternalServiceApiHandlerArgs {
+ params: ExecutorSubActionGetIncidentParams;
}
-export interface UpdateIncidentRequest {
- fields: Partial;
+export interface HandshakeApiHandlerArgs extends ExternalServiceApiHandlerArgs {
+ params: ExecutorSubActionHandshakeParams;
}
-export interface CreateCommentRequest {
- body: string;
+export interface GetIssueTypesHandlerArgs {
+ externalService: ExternalService;
+ params: ExecutorSubActionGetIssueTypesParams;
+}
+
+export interface GetFieldsByIssueTypeHandlerArgs {
+ externalService: ExternalService;
+ params: ExecutorSubActionGetFieldsByIssueTypeParams;
+}
+
+export interface PushToServiceResponse extends ExternalServiceIncidentResponse {
+ comments?: ExternalServiceCommentResponse[];
+}
+
+export interface ExternalServiceApi {
+ handshake: (args: HandshakeApiHandlerArgs) => Promise;
+ pushToService: (args: PushToServiceApiHandlerArgs) => Promise;
+ getIncident: (args: GetIncidentApiHandlerArgs) => Promise;
+ issueTypes: (args: GetIssueTypesHandlerArgs) => Promise;
+ fieldsByIssueType: (
+ args: GetFieldsByIssueTypeHandlerArgs
+ ) => Promise;
+}
+
+export type JiraExecutorResultData =
+ | PushToServiceResponse
+ | GetIssueTypesResponse
+ | GetFieldsByIssueTypeResponse;
+
+export interface Fields {
+ [key: string]: string | string[] | { name: string } | { key: string } | { id: string };
+}
+export interface ResponseError {
+ [k: string]: string;
}
diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/validators.ts
index 7226071392bc6..58a3e27247fae 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/jira/validators.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/jira/validators.ts
@@ -4,8 +4,38 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { validateCommonConfig, validateCommonSecrets } from '../case/validators';
-import { ExternalServiceValidation } from '../case/types';
+import { isEmpty } from 'lodash';
+import { ActionsConfigurationUtilities } from '../../actions_config';
+import {
+ JiraPublicConfigurationType,
+ JiraSecretConfigurationType,
+ ExternalServiceValidation,
+} from './types';
+
+import * as i18n from './translations';
+
+export const validateCommonConfig = (
+ configurationUtilities: ActionsConfigurationUtilities,
+ configObject: JiraPublicConfigurationType
+) => {
+ if (
+ configObject.incidentConfiguration !== null &&
+ isEmpty(configObject.incidentConfiguration.mapping)
+ ) {
+ return i18n.MAPPING_EMPTY;
+ }
+
+ try {
+ configurationUtilities.ensureUriAllowed(configObject.apiUrl);
+ } catch (allowedListError) {
+ return i18n.ALLOWED_HOSTS_ERROR(allowedListError.message);
+ }
+};
+
+export const validateCommonSecrets = (
+ configurationUtilities: ActionsConfigurationUtilities,
+ secrets: JiraSecretConfigurationType
+) => {};
export const validate: ExternalServiceValidation = {
config: validateCommonConfig,
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts
index 0bb096ecd0f62..7a68781bb9a75 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts
@@ -91,7 +91,7 @@ describe('api', () => {
expect(externalService.updateIncident).not.toHaveBeenCalled();
});
- test('it calls updateIncident correctly', async () => {
+ test('it calls updateIncident correctly when creating an incident and having comments', async () => {
const params = { ...apiParams, externalId: null };
await api.pushToService({
externalService,
@@ -103,7 +103,7 @@ describe('api', () => {
expect(externalService.updateIncident).toHaveBeenCalledTimes(2);
expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, {
incident: {
- comments: 'A comment',
+ comments: 'A comment (added at 2020-03-13T08:34:53.450Z by Elastic User)',
description:
'Incident description (created at 2020-03-13T08:34:53.450Z by Elastic User)',
short_description:
@@ -114,7 +114,7 @@ describe('api', () => {
expect(externalService.updateIncident).toHaveBeenNthCalledWith(2, {
incident: {
- comments: 'Another comment',
+ comments: 'Another comment (added at 2020-03-13T08:34:53.450Z by Elastic User)',
description:
'Incident description (created at 2020-03-13T08:34:53.450Z by Elastic User)',
short_description:
@@ -215,7 +215,7 @@ describe('api', () => {
expect(externalService.updateIncident).toHaveBeenNthCalledWith(2, {
incident: {
- comments: 'A comment',
+ comments: 'A comment (added at 2020-03-13T08:34:53.450Z by Elastic User)',
description:
'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
short_description:
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts
index 3281832941558..c8e6147ecef46 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts
@@ -10,11 +10,13 @@ import {
HandshakeApiHandlerArgs,
GetIncidentApiHandlerArgs,
ExternalServiceApi,
+ PushToServiceApiParams,
+ PushToServiceResponse,
} from './types';
// TODO: to remove, need to support Case
import { transformers } from '../case/transformers';
-import { PushToServiceResponse, TransformFieldsArgs } from './case_types';
+import { TransformFieldsArgs, Comment, EntityInformation } from '../case/common_types';
import { prepareFieldsForTransformation } from '../case/utils';
const handshakeHandler = async ({
@@ -92,9 +94,10 @@ const pushToServiceHandler = async ({
mapping.get('comments')?.actionType !== 'nothing'
) {
res.comments = [];
+ const commentsTransformed = transformComments(comments, ['informationAdded']);
const fieldsKey = mapping.get('comments')?.target ?? 'comments';
- for (const currentComment of comments) {
+ for (const currentComment of commentsTransformed) {
await externalService.updateIncident({
incidentId: res.id,
incident: {
@@ -118,7 +121,7 @@ export const transformFields = ({
params,
fields,
currentIncident,
-}: TransformFieldsArgs): Record => {
+}: TransformFieldsArgs): Record => {
return fields.reduce((prev, cur) => {
const transform = flow(...cur.pipes.map((p) => transformers[p]));
return {
@@ -126,20 +129,35 @@ export const transformFields = ({
[cur.key]: transform({
value: cur.value,
date: params.updatedAt ?? params.createdAt,
- user:
- (params.updatedBy != null
- ? params.updatedBy.fullName
- ? params.updatedBy.fullName
- : params.updatedBy.username
- : params.createdBy.fullName
- ? params.createdBy.fullName
- : params.createdBy.username) ?? '',
+ user: getEntity(params),
previousValue: currentIncident ? currentIncident[cur.key] : '',
}).value,
};
}, {});
};
+export const transformComments = (comments: Comment[], pipes: string[]): Comment[] => {
+ return comments.map((c) => ({
+ ...c,
+ comment: flow(...pipes.map((p) => transformers[p]))({
+ value: c.comment,
+ date: c.updatedAt ?? c.createdAt,
+ user: getEntity(c),
+ }).value,
+ }));
+};
+
+export const getEntity = (entity: EntityInformation): string =>
+ (entity.updatedBy != null
+ ? entity.updatedBy.fullName
+ ? entity.updatedBy.fullName
+ : entity.updatedBy.username
+ : entity.createdBy != null
+ ? entity.createdBy.fullName
+ ? entity.createdBy.fullName
+ : entity.createdBy.username
+ : '') ?? '';
+
export const api: ExternalServiceApi = {
handshake: handshakeHandler,
pushToService: pushToServiceHandler,
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts
index 3addbe7c54dac..41a577918b18e 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts
@@ -24,11 +24,11 @@ import {
ExecutorSubActionPushParams,
ServiceNowPublicConfigurationType,
ServiceNowSecretConfigurationType,
+ PushToServiceResponse,
} from './types';
// TODO: to remove, need to support Case
import { buildMap, mapParams } from '../case/utils';
-import { PushToServiceResponse } from './case_types';
interface GetActionTypeParams {
logger: Logger;
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts
index 5f22fcd4fdc85..55a14e4528acf 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts
@@ -5,7 +5,7 @@
*/
import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types';
-import { MapRecord } from './case_types';
+import { MapRecord } from '../case/common_types';
const createMock = (): jest.Mocked => {
const service = {
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts
index 82afebaaee445..921de42adfcaf 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts
@@ -5,7 +5,11 @@
*/
import { schema } from '@kbn/config-schema';
-import { CommentSchema, EntityInformation, IncidentConfigurationSchema } from './case_shema';
+import {
+ CommentSchema,
+ EntityInformation,
+ IncidentConfigurationSchema,
+} from '../case/common_schema';
export const ExternalIncidentServiceConfiguration = {
apiUrl: schema.string(),
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts
index 05c7d805a1852..7cc97a241c4bc 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts
@@ -10,8 +10,8 @@ export const NAME = i18n.translate('xpack.actions.builtin.servicenowTitle', {
defaultMessage: 'ServiceNow',
});
-export const WHITE_LISTED_ERROR = (message: string) =>
- i18n.translate('xpack.actions.builtin.configuration.apiWhitelistError', {
+export const ALLOWED_HOSTS_ERROR = (message: string) =>
+ i18n.translate('xpack.actions.builtin.configuration.apiAllowedHostsError', {
defaultMessage: 'error configuring connector action: {message}',
values: {
message,
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts
index 0db9b6642ea5c..e8fcfac45d789 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts
@@ -16,8 +16,8 @@ import {
ExecutorSubActionHandshakeParamsSchema,
} from './schema';
import { ActionsConfigurationUtilities } from '../../actions_config';
-import { IncidentConfigurationSchema } from './case_shema';
-import { PushToServiceResponse } from './case_types';
+import { ExternalServiceCommentResponse } from '../case/common_types';
+import { IncidentConfigurationSchema } from '../case/common_schema';
import { Logger } from '../../../../../../src/core/server';
export type ServiceNowPublicConfigurationType = TypeOf<
@@ -52,6 +52,9 @@ export interface ExternalServiceIncidentResponse {
url: string;
pushedDate: string;
}
+export interface PushToServiceResponse extends ExternalServiceIncidentResponse {
+ comments?: ExternalServiceCommentResponse[];
+}
export type ExternalServiceParams = Record;
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts
index 6eec3b8d63b86..87bbfd9c7ea95 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts
@@ -27,8 +27,8 @@ export const validateCommonConfig = (
try {
configurationUtilities.ensureUriAllowed(configObject.apiUrl);
- } catch (allowListError) {
- return i18n.WHITE_LISTED_ERROR(allowListError.message);
+ } catch (allowedListError) {
+ return i18n.ALLOWED_HOSTS_ERROR(allowedListError.message);
}
};
diff --git a/x-pack/plugins/apm/scripts/create-functional-tests-archive/index.ts b/x-pack/plugins/apm/scripts/create-functional-tests-archive/index.ts
index cbd63262bd08d..723ff03dc4995 100644
--- a/x-pack/plugins/apm/scripts/create-functional-tests-archive/index.ts
+++ b/x-pack/plugins/apm/scripts/create-functional-tests-archive/index.ts
@@ -83,8 +83,9 @@ async function run() {
},
};
- const archivesDir = path.join(__dirname, '.archives');
const root = path.join(__dirname, '../../../../..');
+ const commonDir = path.join(root, 'x-pack/test/apm_api_integration/common');
+ const archivesDir = path.join(commonDir, 'fixtures/es_archiver');
// create the archive
@@ -98,67 +99,30 @@ async function run() {
}
);
- const targetDirs = ['trial', 'basic'];
-
- // copy the archives to the test fixtures
-
- await Promise.all(
- targetDirs.map(async (target) => {
- const targetPath = path.resolve(
- __dirname,
- '../../../../test/apm_api_integration/',
- target
- );
- const targetArchivesPath = path.resolve(
- targetPath,
- 'fixtures/es_archiver',
- archiveName
- );
-
- if (!fs.existsSync(targetArchivesPath)) {
- fs.mkdirSync(targetArchivesPath);
- }
-
- fs.copyFileSync(
- path.join(archivesDir, archiveName, 'data.json.gz'),
- path.join(targetArchivesPath, 'data.json.gz')
- );
- fs.copyFileSync(
- path.join(archivesDir, archiveName, 'mappings.json'),
- path.join(targetArchivesPath, 'mappings.json')
- );
-
- const currentConfig = {};
-
- // get the current metadata and extend/override metadata for the new archive
- const configFilePath = path.join(targetPath, 'archives_metadata.ts');
-
- try {
- Object.assign(currentConfig, (await import(configFilePath)).default);
- } catch (error) {
- // do nothing
- }
-
- const newConfig = {
- ...currentConfig,
- [archiveName]: {
- start: gte,
- end: lt,
- },
- };
-
- fs.writeFileSync(
- configFilePath,
- `export default ${JSON.stringify(newConfig, null, 2)}`,
- { encoding: 'utf-8' }
- );
- })
- );
+ const currentConfig = {};
+
+ // get the current metadata and extend/override metadata for the new archive
+ const configFilePath = path.join(commonDir, 'archives_metadata.ts');
+
+ try {
+ Object.assign(currentConfig, (await import(configFilePath)).default);
+ } catch (error) {
+ // do nothing
+ }
- fs.unlinkSync(path.join(archivesDir, archiveName, 'data.json.gz'));
- fs.unlinkSync(path.join(archivesDir, archiveName, 'mappings.json'));
- fs.rmdirSync(path.join(archivesDir, archiveName));
- fs.rmdirSync(archivesDir);
+ const newConfig = {
+ ...currentConfig,
+ [archiveName]: {
+ start: gte,
+ end: lt,
+ },
+ };
+
+ fs.writeFileSync(
+ configFilePath,
+ `export default ${JSON.stringify(newConfig, null, 2)}`,
+ { encoding: 'utf-8' }
+ );
// run ESLint on the generated metadata files
diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts
index bd12c258a5388..15a318002390f 100644
--- a/x-pack/plugins/case/common/constants.ts
+++ b/x-pack/plugins/case/common/constants.ts
@@ -28,5 +28,11 @@ export const CASE_USER_ACTIONS_URL = `${CASE_DETAILS_URL}/user_actions`;
export const ACTION_URL = '/api/actions';
export const ACTION_TYPES_URL = '/api/actions/list_action_types';
export const SERVICENOW_ACTION_TYPE_ID = '.servicenow';
+export const JIRA_ACTION_TYPE_ID = '.jira';
+export const RESILIENT_ACTION_TYPE_ID = '.resilient';
-export const SUPPORTED_CONNECTORS = ['.servicenow', '.jira', '.resilient'];
+export const SUPPORTED_CONNECTORS = [
+ SERVICENOW_ACTION_TYPE_ID,
+ JIRA_ACTION_TYPE_ID,
+ RESILIENT_ACTION_TYPE_ID,
+];
diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts
index 28e75dd2f8c32..a22d7ae5cea21 100644
--- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts
+++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts
@@ -12,6 +12,7 @@ import {
CASE_CONFIGURE_CONNECTORS_URL,
SUPPORTED_CONNECTORS,
SERVICENOW_ACTION_TYPE_ID,
+ JIRA_ACTION_TYPE_ID,
} from '../../../../../common/constants';
/*
@@ -36,8 +37,9 @@ export function initCaseConfigureGetActionConnector({ caseService, router }: Rou
(action) =>
SUPPORTED_CONNECTORS.includes(action.actionTypeId) &&
// Need this filtering temporary to display only Case owned ServiceNow connectors
- (action.actionTypeId !== SERVICENOW_ACTION_TYPE_ID ||
- (action.actionTypeId === SERVICENOW_ACTION_TYPE_ID && action.config!.isCaseOwned))
+ (![SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID].includes(action.actionTypeId) ||
+ ([SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID].includes(action.actionTypeId) &&
+ action.config?.isCaseOwned === true))
);
return response.ok({ body: results });
} catch (error) {
diff --git a/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts b/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts
index 2d31be65dd30e..4533383ebd80e 100644
--- a/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts
+++ b/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts
@@ -7,9 +7,20 @@
export const DEFAULT_INITIAL_APP_DATA = {
readOnlyMode: false,
ilmEnabled: true,
+ isFederatedAuth: false,
configuredLimits: {
- maxDocumentByteSize: 102400,
- maxEnginesPerMetaEngine: 15,
+ appSearch: {
+ engine: {
+ maxDocumentByteSize: 102400,
+ maxEnginesPerMetaEngine: 15,
+ },
+ },
+ workplaceSearch: {
+ customApiSource: {
+ maxDocumentByteSize: 102400,
+ totalFields: 64,
+ },
+ },
},
appSearch: {
accountId: 'some-id-string',
@@ -29,17 +40,16 @@ export const DEFAULT_INITIAL_APP_DATA = {
},
},
workplaceSearch: {
- canCreateInvitations: true,
- isFederatedAuth: false,
organization: {
name: 'ACME Donuts',
defaultOrgName: 'My Organization',
},
- fpAccount: {
+ account: {
id: 'some-id-string',
groups: ['Default', 'Cats'],
isAdmin: true,
canCreatePersonalSources: true,
+ canCreateInvitations: true,
isCurated: false,
viewedOnboardingPage: true,
},
diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts
index 05d27d7337a6e..c6ca0d532ce07 100644
--- a/x-pack/plugins/enterprise_search/common/constants.ts
+++ b/x-pack/plugins/enterprise_search/common/constants.ts
@@ -11,7 +11,24 @@ export const ENTERPRISE_SEARCH_PLUGIN = {
NAME: i18n.translate('xpack.enterpriseSearch.productName', {
defaultMessage: 'Enterprise Search',
}),
- URL: '/app/enterprise_search',
+ NAV_TITLE: i18n.translate('xpack.enterpriseSearch.navTitle', {
+ defaultMessage: 'Overview',
+ }),
+ SUBTITLE: i18n.translate('xpack.enterpriseSearch.featureCatalogue.subtitle', {
+ defaultMessage: 'Search everything',
+ }),
+ DESCRIPTIONS: [
+ i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription1', {
+ defaultMessage: 'Build a powerful search experience.',
+ }),
+ i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription2', {
+ defaultMessage: 'Connect your users to relevant data.',
+ }),
+ i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription3', {
+ defaultMessage: 'Unify your team content.',
+ }),
+ ],
+ URL: '/app/enterprise_search/overview',
};
export const APP_SEARCH_PLUGIN = {
@@ -23,6 +40,10 @@ export const APP_SEARCH_PLUGIN = {
defaultMessage:
'Leverage dashboards, analytics, and APIs for advanced application search made simple.',
}),
+ CARD_DESCRIPTION: i18n.translate('xpack.enterpriseSearch.appSearch.productCardDescription', {
+ defaultMessage:
+ 'Elastic App Search provides user-friendly tools to design and deploy a powerful search to your websites or web/mobile applications.',
+ }),
URL: '/app/enterprise_search/app_search',
SUPPORT_URL: 'https://discuss.elastic.co/c/enterprise-search/app-search/',
};
@@ -36,12 +57,22 @@ export const WORKPLACE_SEARCH_PLUGIN = {
defaultMessage:
'Search all documents, files, and sources available across your virtual workplace.',
}),
+ CARD_DESCRIPTION: i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.productCardDescription',
+ {
+ defaultMessage:
+ "Unify all your team's content in one place, with instant connectivity to popular productivity and collaboration tools.",
+ }
+ ),
URL: '/app/enterprise_search/workplace_search',
SUPPORT_URL: 'https://discuss.elastic.co/c/enterprise-search/workplace-search/',
};
export const LICENSED_SUPPORT_URL = 'https://support.elastic.co';
-export const JSON_HEADER = { 'Content-Type': 'application/json' }; // This needs specific casing or Chrome throws a 415 error
+export const JSON_HEADER = {
+ 'Content-Type': 'application/json', // This needs specific casing or Chrome throws a 415 error
+ Accept: 'application/json', // Required for Enterprise Search APIs
+};
export const ENGINES_PAGE_SIZE = 10;
diff --git a/x-pack/plugins/enterprise_search/common/types/app_search.ts b/x-pack/plugins/enterprise_search/common/types/app_search.ts
index 5d6ec079e66e0..72259ecd2343d 100644
--- a/x-pack/plugins/enterprise_search/common/types/app_search.ts
+++ b/x-pack/plugins/enterprise_search/common/types/app_search.ts
@@ -23,3 +23,10 @@ export interface IRole {
availableRoleTypes: string[];
};
}
+
+export interface IConfiguredLimits {
+ engine: {
+ maxDocumentByteSize: number;
+ maxEnginesPerMetaEngine: number;
+ };
+}
diff --git a/x-pack/plugins/enterprise_search/common/types/index.ts b/x-pack/plugins/enterprise_search/common/types/index.ts
index 008afb234a376..d5774adc0d516 100644
--- a/x-pack/plugins/enterprise_search/common/types/index.ts
+++ b/x-pack/plugins/enterprise_search/common/types/index.ts
@@ -4,18 +4,29 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { IAccount as IAppSearchAccount } from './app_search';
-import { IWorkplaceSearchInitialData } from './workplace_search';
+import {
+ IAccount as IAppSearchAccount,
+ IConfiguredLimits as IAppSearchConfiguredLimits,
+} from './app_search';
+import {
+ IWorkplaceSearchInitialData,
+ IConfiguredLimits as IWorkplaceSearchConfiguredLimits,
+} from './workplace_search';
export interface IInitialAppData {
readOnlyMode?: boolean;
ilmEnabled?: boolean;
+ isFederatedAuth?: boolean;
configuredLimits?: IConfiguredLimits;
+ access?: {
+ hasAppSearchAccess: boolean;
+ hasWorkplaceSearchAccess: boolean;
+ };
appSearch?: IAppSearchAccount;
workplaceSearch?: IWorkplaceSearchInitialData;
}
export interface IConfiguredLimits {
- maxDocumentByteSize: number;
- maxEnginesPerMetaEngine: number;
+ appSearch: IAppSearchConfiguredLimits;
+ workplaceSearch: IWorkplaceSearchConfiguredLimits;
}
diff --git a/x-pack/plugins/enterprise_search/common/types/workplace_search.ts b/x-pack/plugins/enterprise_search/common/types/workplace_search.ts
index bc4e39b0788d9..6c82206706b32 100644
--- a/x-pack/plugins/enterprise_search/common/types/workplace_search.ts
+++ b/x-pack/plugins/enterprise_search/common/types/workplace_search.ts
@@ -10,6 +10,7 @@ export interface IAccount {
isAdmin: boolean;
isCurated: boolean;
canCreatePersonalSources: boolean;
+ canCreateInvitations?: boolean;
viewedOnboardingPage: boolean;
}
@@ -19,8 +20,13 @@ export interface IOrganization {
}
export interface IWorkplaceSearchInitialData {
- canCreateInvitations: boolean;
- isFederatedAuth: boolean;
organization: IOrganization;
- fpAccount: IAccount;
+ account: IAccount;
+}
+
+export interface IConfiguredLimits {
+ customApiSource: {
+ maxDocumentByteSize: number;
+ totalFields: number;
+ };
}
diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts
index 779eb1a043e8c..842dcefd3aef8 100644
--- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts
@@ -9,7 +9,7 @@
* Jest to accept its use within a jest.mock()
*/
export const mockHistory = {
- createHref: jest.fn(({ pathname }) => `/enterprise_search${pathname}`),
+ createHref: jest.fn(({ pathname }) => `/app/enterprise_search${pathname}`),
push: jest.fn(),
location: {
pathname: '/current-path',
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/app_search.png b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/app_search.png
new file mode 100644
index 0000000000000..6cf0639167e2f
Binary files /dev/null and b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/app_search.png differ
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/bg_enterprise_search.png b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/bg_enterprise_search.png
new file mode 100644
index 0000000000000..1b5e1e489fd96
Binary files /dev/null and b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/bg_enterprise_search.png differ
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/workplace_search.png b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/workplace_search.png
new file mode 100644
index 0000000000000..984662b65cb5d
Binary files /dev/null and b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/workplace_search.png differ
diff --git a/x-pack/test/apm_api_integration/trial/archives_metadata.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/index.ts
similarity index 67%
rename from x-pack/test/apm_api_integration/trial/archives_metadata.ts
rename to x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/index.ts
index f3228176db8d6..df85a10f7e9de 100644
--- a/x-pack/test/apm_api_integration/trial/archives_metadata.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/index.ts
@@ -4,9 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export default {
- 'apm_8.0.0': {
- start: '2020-09-09T06:11:22.998Z',
- end: '2020-09-09T06:41:22.998Z',
- },
-};
+export { ProductCard } from './product_card';
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.scss b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.scss
new file mode 100644
index 0000000000000..d6b6bd3442590
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.scss
@@ -0,0 +1,58 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+.productCard {
+ margin: $euiSizeS;
+
+ &__imageContainer {
+ max-height: 115px;
+ overflow: hidden;
+ background-color: #0076cc;
+
+ @include euiBreakpoint('s', 'm', 'l', 'xl') {
+ max-height: none;
+ }
+ }
+
+ &__image {
+ width: 100%;
+ height: auto;
+ }
+
+ .euiCard__content {
+ max-width: 350px;
+ margin-top: $euiSizeL;
+
+ @include euiBreakpoint('s', 'm', 'l', 'xl') {
+ margin-top: $euiSizeXL;
+ }
+ }
+
+ .euiCard__title {
+ margin-bottom: $euiSizeM;
+ font-weight: $euiFontWeightBold;
+
+ @include euiBreakpoint('s', 'm', 'l', 'xl') {
+ margin-bottom: $euiSizeL;
+ font-size: $euiSizeL;
+ }
+ }
+
+ .euiCard__description {
+ font-weight: $euiFontWeightMedium;
+ color: $euiColorMediumShade;
+ margin-bottom: $euiSize;
+ }
+
+ .euiCard__footer {
+ margin-bottom: $euiSizeS;
+
+ @include euiBreakpoint('s', 'm', 'l', 'xl') {
+ margin-bottom: $euiSizeM;
+ font-size: $euiSizeL;
+ }
+ }
+}
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.test.tsx
new file mode 100644
index 0000000000000..a76b654ccddd0
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.test.tsx
@@ -0,0 +1,57 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { EuiCard } from '@elastic/eui';
+import { EuiButton } from '../../../shared/react_router_helpers';
+import { APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants';
+
+jest.mock('../../../shared/telemetry', () => ({
+ sendTelemetry: jest.fn(),
+}));
+import { sendTelemetry } from '../../../shared/telemetry';
+
+import { ProductCard } from './';
+
+describe('ProductCard', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders an App Search card', () => {
+ const wrapper = shallow();
+ const card = wrapper.find(EuiCard).dive().shallow();
+
+ expect(card.find('h2').text()).toEqual('Elastic App Search');
+ expect(card.find('.productCard__image').prop('src')).toEqual('as.jpg');
+
+ const button = card.find(EuiButton);
+ expect(button.prop('to')).toEqual('/app/enterprise_search/app_search');
+ expect(button.prop('data-test-subj')).toEqual('LaunchAppSearchButton');
+
+ button.simulate('click');
+ expect(sendTelemetry).toHaveBeenCalledWith(expect.objectContaining({ metric: 'app_search' }));
+ });
+
+ it('renders a Workplace Search card', () => {
+ const wrapper = shallow();
+ const card = wrapper.find(EuiCard).dive().shallow();
+
+ expect(card.find('h2').text()).toEqual('Elastic Workplace Search');
+ expect(card.find('.productCard__image').prop('src')).toEqual('ws.jpg');
+
+ const button = card.find(EuiButton);
+ expect(button.prop('to')).toEqual('/app/enterprise_search/workplace_search');
+ expect(button.prop('data-test-subj')).toEqual('LaunchWorkplaceSearchButton');
+
+ button.simulate('click');
+ expect(sendTelemetry).toHaveBeenCalledWith(
+ expect.objectContaining({ metric: 'workplace_search' })
+ );
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.tsx
new file mode 100644
index 0000000000000..334ca126cabb9
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.tsx
@@ -0,0 +1,71 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useContext } from 'react';
+import upperFirst from 'lodash/upperFirst';
+import snakeCase from 'lodash/snakeCase';
+import { i18n } from '@kbn/i18n';
+import { EuiCard, EuiTextColor } from '@elastic/eui';
+
+import { EuiButton } from '../../../shared/react_router_helpers';
+import { sendTelemetry } from '../../../shared/telemetry';
+import { KibanaContext, IKibanaContext } from '../../../index';
+
+import './product_card.scss';
+
+interface IProductCard {
+ // Expects product plugin constants (@see common/constants.ts)
+ product: {
+ ID: string;
+ NAME: string;
+ CARD_DESCRIPTION: string;
+ URL: string;
+ };
+ image: string;
+}
+
+export const ProductCard: React.FC = ({ product, image }) => {
+ const { http } = useContext(KibanaContext) as IKibanaContext;
+
+ return (
+
+
+
+ }
+ paddingSize="l"
+ description={{product.CARD_DESCRIPTION}}
+ footer={
+
+ sendTelemetry({
+ http,
+ product: 'enterprise_search',
+ action: 'clicked',
+ metric: snakeCase(product.ID),
+ })
+ }
+ data-test-subj={`Launch${upperFirst(product.ID)}Button`}
+ >
+ {i18n.translate('xpack.enterpriseSearch.overview.productCard.button', {
+ defaultMessage: `Launch {productName}`,
+ values: { productName: product.NAME },
+ })}
+
+ }
+ />
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.scss b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.scss
new file mode 100644
index 0000000000000..d937943352317
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.scss
@@ -0,0 +1,54 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+.enterpriseSearchOverview {
+ padding-top: 78px;
+ background-image: url('./assets/bg_enterprise_search.png');
+ background-repeat: no-repeat;
+ background-size: 670px;
+ background-position: center -27px;
+
+ @include euiBreakpoint('m', 'l', 'xl') {
+ padding-top: 158px;
+ background-size: 1160px;
+ background-position: center -48px;
+ }
+
+ &__header {
+ text-align: center;
+ margin: auto;
+ }
+
+ &__heading {
+ @include euiBreakpoint('xs', 's') {
+ font-size: $euiFontSizeXL;
+ line-height: map-get(map-get($euiTitles, 'm'), 'line-height');
+ }
+ }
+
+ &__subheading {
+ color: $euiColorMediumShade;
+ font-size: $euiFontSize;
+
+ @include euiBreakpoint('m', 'l', 'xl') {
+ font-size: $euiFontSizeL;
+ margin-bottom: $euiSizeL;
+ }
+ }
+
+ // EUI override
+ .euiTitle + .euiTitle {
+ margin-top: 0;
+
+ @include euiBreakpoint('m', 'l', 'xl') {
+ margin-top: $euiSizeS;
+ }
+ }
+
+ .enterpriseSearchOverview__card {
+ flex-basis: 50%;
+ }
+}
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx
new file mode 100644
index 0000000000000..cd2a22a45bbb4
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx
@@ -0,0 +1,50 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { EuiPage } from '@elastic/eui';
+
+import { EnterpriseSearch } from './';
+import { ProductCard } from './components/product_card';
+
+describe('EnterpriseSearch', () => {
+ it('renders the overview page and product cards', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.find(EuiPage).hasClass('enterpriseSearchOverview')).toBe(true);
+ expect(wrapper.find(ProductCard)).toHaveLength(2);
+ });
+
+ describe('access checks', () => {
+ it('does not render the App Search card if the user does not have access to AS', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.find(ProductCard)).toHaveLength(1);
+ expect(wrapper.find(ProductCard).prop('product').ID).toEqual('workplaceSearch');
+ });
+
+ it('does not render the Workplace Search card if the user does not have access to WS', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.find(ProductCard)).toHaveLength(1);
+ expect(wrapper.find(ProductCard).prop('product').ID).toEqual('appSearch');
+ });
+
+ it('does not render any cards if the user does not have access', () => {
+ const wrapper = shallow();
+
+ expect(wrapper.find(ProductCard)).toHaveLength(0);
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx
new file mode 100644
index 0000000000000..373f595a6a9ea
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx
@@ -0,0 +1,78 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import {
+ EuiPage,
+ EuiPageBody,
+ EuiPageHeader,
+ EuiPageHeaderSection,
+ EuiPageContentBody,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSpacer,
+ EuiTitle,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+
+import { IInitialAppData } from '../../../common/types';
+import { APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN } from '../../../common/constants';
+
+import { SetEnterpriseSearchChrome as SetPageChrome } from '../shared/kibana_chrome';
+import { SendEnterpriseSearchTelemetry as SendTelemetry } from '../shared/telemetry';
+
+import { ProductCard } from './components/product_card';
+
+import AppSearchImage from './assets/app_search.png';
+import WorkplaceSearchImage from './assets/workplace_search.png';
+import './index.scss';
+
+export const EnterpriseSearch: React.FC = ({ access = {} }) => {
+ const { hasAppSearchAccess, hasWorkplaceSearchAccess } = access;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {i18n.translate('xpack.enterpriseSearch.overview.heading', {
+ defaultMessage: 'Welcome to Elastic Enterprise Search',
+ })}
+
+
+
+
+ {i18n.translate('xpack.enterpriseSearch.overview.subheading', {
+ defaultMessage: 'Select a product to get started',
+ })}
+
+
+
+
+
+
+ {hasAppSearchAccess && (
+
+
+
+ )}
+ {hasWorkplaceSearchAccess && (
+
+
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.test.ts
index 9e86b239432a7..3c8b3a7218862 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.test.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.test.ts
@@ -37,27 +37,37 @@ describe('useBreadcrumbs', () => {
expect(breadcrumb).toEqual([
{
text: 'Hello',
- href: '/enterprise_search/hello',
+ href: '/app/enterprise_search/hello',
onClick: expect.any(Function),
},
{
text: 'World',
- href: '/enterprise_search/world',
+ href: '/app/enterprise_search/world',
onClick: expect.any(Function),
},
]);
});
it('prevents default navigation and uses React Router history on click', () => {
- const breadcrumb = useBreadcrumbs([{ text: '', path: '/' }])[0] as any;
+ const breadcrumb = useBreadcrumbs([{ text: '', path: '/test' }])[0] as any;
const event = { preventDefault: jest.fn() };
breadcrumb.onClick(event);
- expect(mockKibanaContext.navigateToUrl).toHaveBeenCalled();
+ expect(mockKibanaContext.navigateToUrl).toHaveBeenCalledWith('/app/enterprise_search/test');
expect(mockHistory.createHref).toHaveBeenCalled();
expect(event.preventDefault).toHaveBeenCalled();
});
+ it('does not call createHref if shouldNotCreateHref is passed', () => {
+ const breadcrumb = useBreadcrumbs([
+ { text: '', path: '/test', shouldNotCreateHref: true },
+ ])[0] as any;
+ breadcrumb.onClick({ preventDefault: () => null });
+
+ expect(mockKibanaContext.navigateToUrl).toHaveBeenCalledWith('/test');
+ expect(mockHistory.createHref).not.toHaveBeenCalled();
+ });
+
it('does not prevent default browser behavior on new tab/window clicks', () => {
const breadcrumb = useBreadcrumbs([{ text: '', path: '/' }])[0] as any;
@@ -95,15 +105,17 @@ describe('useEnterpriseSearchBreadcrumbs', () => {
expect(useEnterpriseSearchBreadcrumbs(breadcrumbs)).toEqual([
{
text: 'Enterprise Search',
+ href: '/app/enterprise_search/overview',
+ onClick: expect.any(Function),
},
{
text: 'Page 1',
- href: '/enterprise_search/page1',
+ href: '/app/enterprise_search/page1',
onClick: expect.any(Function),
},
{
text: 'Page 2',
- href: '/enterprise_search/page2',
+ href: '/app/enterprise_search/page2',
onClick: expect.any(Function),
},
]);
@@ -113,6 +125,8 @@ describe('useEnterpriseSearchBreadcrumbs', () => {
expect(useEnterpriseSearchBreadcrumbs()).toEqual([
{
text: 'Enterprise Search',
+ href: '/app/enterprise_search/overview',
+ onClick: expect.any(Function),
},
]);
});
@@ -122,7 +136,7 @@ describe('useAppSearchBreadcrumbs', () => {
beforeEach(() => {
jest.clearAllMocks();
mockHistory.createHref.mockImplementation(
- ({ pathname }: any) => `/enterprise_search/app_search${pathname}`
+ ({ pathname }: any) => `/app/enterprise_search/app_search${pathname}`
);
});
@@ -141,20 +155,22 @@ describe('useAppSearchBreadcrumbs', () => {
expect(useAppSearchBreadcrumbs(breadcrumbs)).toEqual([
{
text: 'Enterprise Search',
+ href: '/app/enterprise_search/overview',
+ onClick: expect.any(Function),
},
{
text: 'App Search',
- href: '/enterprise_search/app_search/',
+ href: '/app/enterprise_search/app_search/',
onClick: expect.any(Function),
},
{
text: 'Page 1',
- href: '/enterprise_search/app_search/page1',
+ href: '/app/enterprise_search/app_search/page1',
onClick: expect.any(Function),
},
{
text: 'Page 2',
- href: '/enterprise_search/app_search/page2',
+ href: '/app/enterprise_search/app_search/page2',
onClick: expect.any(Function),
},
]);
@@ -164,10 +180,12 @@ describe('useAppSearchBreadcrumbs', () => {
expect(useAppSearchBreadcrumbs()).toEqual([
{
text: 'Enterprise Search',
+ href: '/app/enterprise_search/overview',
+ onClick: expect.any(Function),
},
{
text: 'App Search',
- href: '/enterprise_search/app_search/',
+ href: '/app/enterprise_search/app_search/',
onClick: expect.any(Function),
},
]);
@@ -178,7 +196,7 @@ describe('useWorkplaceSearchBreadcrumbs', () => {
beforeEach(() => {
jest.clearAllMocks();
mockHistory.createHref.mockImplementation(
- ({ pathname }: any) => `/enterprise_search/workplace_search${pathname}`
+ ({ pathname }: any) => `/app/enterprise_search/workplace_search${pathname}`
);
});
@@ -197,20 +215,22 @@ describe('useWorkplaceSearchBreadcrumbs', () => {
expect(useWorkplaceSearchBreadcrumbs(breadcrumbs)).toEqual([
{
text: 'Enterprise Search',
+ href: '/app/enterprise_search/overview',
+ onClick: expect.any(Function),
},
{
text: 'Workplace Search',
- href: '/enterprise_search/workplace_search/',
+ href: '/app/enterprise_search/workplace_search/',
onClick: expect.any(Function),
},
{
text: 'Page 1',
- href: '/enterprise_search/workplace_search/page1',
+ href: '/app/enterprise_search/workplace_search/page1',
onClick: expect.any(Function),
},
{
text: 'Page 2',
- href: '/enterprise_search/workplace_search/page2',
+ href: '/app/enterprise_search/workplace_search/page2',
onClick: expect.any(Function),
},
]);
@@ -220,10 +240,12 @@ describe('useWorkplaceSearchBreadcrumbs', () => {
expect(useWorkplaceSearchBreadcrumbs()).toEqual([
{
text: 'Enterprise Search',
+ href: '/app/enterprise_search/overview',
+ onClick: expect.any(Function),
},
{
text: 'Workplace Search',
- href: '/enterprise_search/workplace_search/',
+ href: '/app/enterprise_search/workplace_search/',
onClick: expect.any(Function),
},
]);
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts
index 6eab936719d01..19714608e73e9 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts
@@ -26,6 +26,9 @@ import { letBrowserHandleEvent } from '../react_router_helpers';
interface IBreadcrumb {
text: string;
path?: string;
+ // Used to navigate outside of the React Router basename,
+ // i.e. if we need to go from App Search to Enterprise Search
+ shouldNotCreateHref?: boolean;
}
export type TBreadcrumbs = IBreadcrumb[];
@@ -33,11 +36,11 @@ export const useBreadcrumbs = (breadcrumbs: TBreadcrumbs) => {
const history = useHistory();
const { navigateToUrl } = useContext(KibanaContext) as IKibanaContext;
- return breadcrumbs.map(({ text, path }) => {
+ return breadcrumbs.map(({ text, path, shouldNotCreateHref }) => {
const breadcrumb = { text } as EuiBreadcrumb;
if (path) {
- const href = history.createHref({ pathname: path }) as string;
+ const href = shouldNotCreateHref ? path : (history.createHref({ pathname: path }) as string);
breadcrumb.href = href;
breadcrumb.onClick = (event) => {
@@ -56,7 +59,14 @@ export const useBreadcrumbs = (breadcrumbs: TBreadcrumbs) => {
*/
export const useEnterpriseSearchBreadcrumbs = (breadcrumbs: TBreadcrumbs = []) =>
- useBreadcrumbs([{ text: ENTERPRISE_SEARCH_PLUGIN.NAME }, ...breadcrumbs]);
+ useBreadcrumbs([
+ {
+ text: ENTERPRISE_SEARCH_PLUGIN.NAME,
+ path: ENTERPRISE_SEARCH_PLUGIN.URL,
+ shouldNotCreateHref: true,
+ },
+ ...breadcrumbs,
+ ]);
export const useAppSearchBreadcrumbs = (breadcrumbs: TBreadcrumbs = []) =>
useEnterpriseSearchBreadcrumbs([{ text: APP_SEARCH_PLUGIN.NAME, path: '/' }, ...breadcrumbs]);
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts
index 706baefc00cc2..de5f72de79192 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts
@@ -20,7 +20,7 @@ export type TTitle = string[];
/**
* Given an array of page titles, return a final formatted document title
* @param pages - e.g., ['Curations', 'some Engine', 'App Search']
- * @returns - e.g., 'Curations | some Engine | App Search'
+ * @returns - e.g., 'Curations - some Engine - App Search'
*/
export const generateTitle = (pages: TTitle) => pages.join(' - ');
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/index.ts
index 4468d11ba94c9..02013a03c3395 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/index.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/index.ts
@@ -4,4 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export { SetAppSearchChrome, SetWorkplaceSearchChrome } from './set_chrome';
+export {
+ SetEnterpriseSearchChrome,
+ SetAppSearchChrome,
+ SetWorkplaceSearchChrome,
+} from './set_chrome';
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.test.tsx
index bda816c9a5554..61a066bb92216 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.test.tsx
@@ -12,18 +12,24 @@ import React from 'react';
import { mockKibanaContext, mountWithKibanaContext } from '../../__mocks__';
jest.mock('./generate_breadcrumbs', () => ({
+ useEnterpriseSearchBreadcrumbs: jest.fn(() => (crumbs: any) => crumbs),
useAppSearchBreadcrumbs: jest.fn(() => (crumbs: any) => crumbs),
useWorkplaceSearchBreadcrumbs: jest.fn(() => (crumbs: any) => crumbs),
}));
-import { useAppSearchBreadcrumbs, useWorkplaceSearchBreadcrumbs } from './generate_breadcrumbs';
+import {
+ useEnterpriseSearchBreadcrumbs,
+ useAppSearchBreadcrumbs,
+ useWorkplaceSearchBreadcrumbs,
+} from './generate_breadcrumbs';
jest.mock('./generate_title', () => ({
+ enterpriseSearchTitle: jest.fn((title: any) => title),
appSearchTitle: jest.fn((title: any) => title),
workplaceSearchTitle: jest.fn((title: any) => title),
}));
-import { appSearchTitle, workplaceSearchTitle } from './generate_title';
+import { enterpriseSearchTitle, appSearchTitle, workplaceSearchTitle } from './generate_title';
-import { SetAppSearchChrome, SetWorkplaceSearchChrome } from './';
+import { SetEnterpriseSearchChrome, SetAppSearchChrome, SetWorkplaceSearchChrome } from './';
describe('Set Kibana Chrome helpers', () => {
beforeEach(() => {
@@ -35,6 +41,27 @@ describe('Set Kibana Chrome helpers', () => {
expect(mockKibanaContext.setDocTitle).toHaveBeenCalled();
});
+ describe('SetEnterpriseSearchChrome', () => {
+ it('sets breadcrumbs and document title', () => {
+ mountWithKibanaContext();
+
+ expect(enterpriseSearchTitle).toHaveBeenCalledWith(['Hello World']);
+ expect(useEnterpriseSearchBreadcrumbs).toHaveBeenCalledWith([
+ {
+ text: 'Hello World',
+ path: '/current-path',
+ },
+ ]);
+ });
+
+ it('sets empty breadcrumbs and document title when isRoot is true', () => {
+ mountWithKibanaContext();
+
+ expect(enterpriseSearchTitle).toHaveBeenCalledWith([]);
+ expect(useEnterpriseSearchBreadcrumbs).toHaveBeenCalledWith([]);
+ });
+ });
+
describe('SetAppSearchChrome', () => {
it('sets breadcrumbs and document title', () => {
mountWithKibanaContext();
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx
index 43db93c1583d1..5e8d972e1a135 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx
@@ -10,11 +10,17 @@ import { EuiBreadcrumb } from '@elastic/eui';
import { KibanaContext, IKibanaContext } from '../../index';
import {
+ useEnterpriseSearchBreadcrumbs,
useAppSearchBreadcrumbs,
useWorkplaceSearchBreadcrumbs,
TBreadcrumbs,
} from './generate_breadcrumbs';
-import { appSearchTitle, workplaceSearchTitle, TTitle } from './generate_title';
+import {
+ enterpriseSearchTitle,
+ appSearchTitle,
+ workplaceSearchTitle,
+ TTitle,
+} from './generate_title';
/**
* Helpers for setting Kibana chrome (breadcrumbs, doc titles) on React view mount
@@ -33,6 +39,24 @@ interface IRootBreadcrumbsProps {
}
type TBreadcrumbsProps = IBreadcrumbsProps | IRootBreadcrumbsProps;
+export const SetEnterpriseSearchChrome: React.FC = ({ text, isRoot }) => {
+ const history = useHistory();
+ const { setBreadcrumbs, setDocTitle } = useContext(KibanaContext) as IKibanaContext;
+
+ const title = isRoot ? [] : [text];
+ const docTitle = enterpriseSearchTitle(title as TTitle | []);
+
+ const crumb = isRoot ? [] : [{ text, path: history.location.pathname }];
+ const breadcrumbs = useEnterpriseSearchBreadcrumbs(crumb as TBreadcrumbs | []);
+
+ useEffect(() => {
+ setBreadcrumbs(breadcrumbs);
+ setDocTitle(docTitle);
+ }, []);
+
+ return null;
+};
+
export const SetAppSearchChrome: React.FC = ({ text, isRoot }) => {
const history = useHistory();
const { setBreadcrumbs, setDocTitle } = useContext(KibanaContext) as IKibanaContext;
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx
index 063118f94cd19..0c7bac99085dd 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx
@@ -45,10 +45,18 @@ describe('EUI & React Router Component Helpers', () => {
const link = wrapper.find(EuiLink);
expect(link.prop('onClick')).toBeInstanceOf(Function);
- expect(link.prop('href')).toEqual('/enterprise_search/foo/bar');
+ expect(link.prop('href')).toEqual('/app/enterprise_search/foo/bar');
expect(mockHistory.createHref).toHaveBeenCalled();
});
+ it('renders with the correct non-basenamed href when shouldNotCreateHref is passed', () => {
+ const wrapper = mount();
+ const link = wrapper.find(EuiLink);
+
+ expect(link.prop('href')).toEqual('/foo/bar');
+ expect(mockHistory.createHref).not.toHaveBeenCalled();
+ });
+
describe('onClick', () => {
it('prevents default navigation and uses React Router history', () => {
const wrapper = mount();
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx
index 7221a61d0997b..e3b46632ddf9e 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx
@@ -21,14 +21,22 @@ import { letBrowserHandleEvent } from './link_events';
interface IEuiReactRouterProps {
to: string;
onClick?(): void;
+ // Used to navigate outside of the React Router plugin basename but still within Kibana,
+ // e.g. if we need to go from Enterprise Search to App Search
+ shouldNotCreateHref?: boolean;
}
-export const EuiReactRouterHelper: React.FC = ({ to, onClick, children }) => {
+export const EuiReactRouterHelper: React.FC = ({
+ to,
+ onClick,
+ shouldNotCreateHref,
+ children,
+}) => {
const history = useHistory();
const { navigateToUrl } = useContext(KibanaContext) as IKibanaContext;
// Generate the correct link href (with basename etc. accounted for)
- const href = history.createHref({ pathname: to });
+ const href = shouldNotCreateHref ? to : history.createHref({ pathname: to });
const reactRouterLinkClick = (event: React.MouseEvent) => {
if (onClick) onClick(); // Run any passed click events (e.g. telemetry)
@@ -51,9 +59,10 @@ type TEuiReactRouterButtonProps = EuiButtonProps & IEuiReactRouterProps;
export const EuiReactRouterLink: React.FC = ({
to,
onClick,
+ shouldNotCreateHref,
...rest
}) => (
-
+
);
@@ -61,9 +70,10 @@ export const EuiReactRouterLink: React.FC = ({
export const EuiReactRouterButton: React.FC = ({
to,
onClick,
+ shouldNotCreateHref,
...rest
}) => (
-
+
);
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts
index eadf7fa805590..a8b9636c3ff3e 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts
@@ -5,5 +5,8 @@
*/
export { sendTelemetry } from './send_telemetry';
-export { SendAppSearchTelemetry } from './send_telemetry';
-export { SendWorkplaceSearchTelemetry } from './send_telemetry';
+export {
+ SendEnterpriseSearchTelemetry,
+ SendAppSearchTelemetry,
+ SendWorkplaceSearchTelemetry,
+} from './send_telemetry';
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx
index 3c873dbc25e37..8f7cf090e2d57 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx
@@ -10,7 +10,12 @@ import { httpServiceMock } from 'src/core/public/mocks';
import { JSON_HEADER as headers } from '../../../../common/constants';
import { mountWithKibanaContext } from '../../__mocks__';
-import { sendTelemetry, SendAppSearchTelemetry, SendWorkplaceSearchTelemetry } from './';
+import {
+ sendTelemetry,
+ SendEnterpriseSearchTelemetry,
+ SendAppSearchTelemetry,
+ SendWorkplaceSearchTelemetry,
+} from './';
describe('Shared Telemetry Helpers', () => {
const httpMock = httpServiceMock.createSetupContract();
@@ -44,6 +49,17 @@ describe('Shared Telemetry Helpers', () => {
});
describe('React component helpers', () => {
+ it('SendEnterpriseSearchTelemetry component', () => {
+ mountWithKibanaContext(, {
+ http: httpMock,
+ });
+
+ expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', {
+ headers,
+ body: '{"product":"enterprise_search","action":"viewed","metric":"page"}',
+ });
+ });
+
it('SendAppSearchTelemetry component', () => {
mountWithKibanaContext(, {
http: httpMock,
@@ -56,13 +72,13 @@ describe('Shared Telemetry Helpers', () => {
});
it('SendWorkplaceSearchTelemetry component', () => {
- mountWithKibanaContext(, {
+ mountWithKibanaContext(, {
http: httpMock,
});
expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', {
headers,
- body: '{"product":"workplace_search","action":"viewed","metric":"page"}',
+ body: '{"product":"workplace_search","action":"error","metric":"not_found"}',
});
});
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx
index 715d61b31512c..4df1428221de6 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx
@@ -35,9 +35,21 @@ export const sendTelemetry = async ({ http, product, action, metric }: ISendTele
/**
* React component helpers - useful for on-page-load/views
- * TODO: SendEnterpriseSearchTelemetry
*/
+export const SendEnterpriseSearchTelemetry: React.FC = ({
+ action,
+ metric,
+}) => {
+ const { http } = useContext(KibanaContext) as IKibanaContext;
+
+ useEffect(() => {
+ sendTelemetry({ http, action, metric, product: 'enterprise_search' });
+ }, [action, metric, http]);
+
+ return null;
+};
+
export const SendAppSearchTelemetry: React.FC = ({ action, metric }) => {
const { http } = useContext(KibanaContext) as IKibanaContext;
diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts
index 83598a0dc971d..b735db7c49520 100644
--- a/x-pack/plugins/enterprise_search/public/plugin.ts
+++ b/x-pack/plugins/enterprise_search/public/plugin.ts
@@ -12,7 +12,6 @@ import {
AppMountParameters,
HttpSetup,
} from 'src/core/public';
-import { i18n } from '@kbn/i18n';
import {
FeatureCatalogueCategory,
HomePublicPluginSetup,
@@ -52,6 +51,25 @@ export class EnterpriseSearchPlugin implements Plugin {
}
public setup(core: CoreSetup, plugins: PluginsSetup) {
+ core.application.register({
+ id: ENTERPRISE_SEARCH_PLUGIN.ID,
+ title: ENTERPRISE_SEARCH_PLUGIN.NAV_TITLE,
+ appRoute: ENTERPRISE_SEARCH_PLUGIN.URL,
+ category: DEFAULT_APP_CATEGORIES.enterpriseSearch,
+ mount: async (params: AppMountParameters) => {
+ const [coreStart] = await core.getStartServices();
+ const { chrome } = coreStart;
+ chrome.docTitle.change(ENTERPRISE_SEARCH_PLUGIN.NAME);
+
+ await this.getInitialData(coreStart.http);
+
+ const { renderApp } = await import('./applications');
+ const { EnterpriseSearch } = await import('./applications/enterprise_search');
+
+ return renderApp(EnterpriseSearch, params, coreStart, plugins, this.config, this.data);
+ },
+ });
+
core.application.register({
id: APP_SEARCH_PLUGIN.ID,
title: APP_SEARCH_PLUGIN.NAME,
@@ -94,22 +112,10 @@ export class EnterpriseSearchPlugin implements Plugin {
plugins.home.featureCatalogue.registerSolution({
id: ENTERPRISE_SEARCH_PLUGIN.ID,
title: ENTERPRISE_SEARCH_PLUGIN.NAME,
- subtitle: i18n.translate('xpack.enterpriseSearch.featureCatalogue.subtitle', {
- defaultMessage: 'Search everything',
- }),
+ subtitle: ENTERPRISE_SEARCH_PLUGIN.SUBTITLE,
icon: 'logoEnterpriseSearch',
- descriptions: [
- i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription1', {
- defaultMessage: 'Build a powerful search experience.',
- }),
- i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription2', {
- defaultMessage: 'Connect your users to relevant data.',
- }),
- i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription3', {
- defaultMessage: 'Unify your team content.',
- }),
- ],
- path: APP_SEARCH_PLUGIN.URL, // TODO: Change this to enterprise search overview page once available
+ descriptions: ENTERPRISE_SEARCH_PLUGIN.DESCRIPTIONS,
+ path: ENTERPRISE_SEARCH_PLUGIN.URL,
});
plugins.home.featureCatalogue.register({
diff --git a/x-pack/plugins/enterprise_search/server/collectors/enterprise_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/enterprise_search/telemetry.test.ts
new file mode 100644
index 0000000000000..c3e2aff6551c9
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/collectors/enterprise_search/telemetry.test.ts
@@ -0,0 +1,85 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { mockLogger } from '../../__mocks__';
+
+import { registerTelemetryUsageCollector } from './telemetry';
+
+describe('Enterprise Search Telemetry Usage Collector', () => {
+ const makeUsageCollectorStub = jest.fn();
+ const registerStub = jest.fn();
+ const usageCollectionMock = {
+ makeUsageCollector: makeUsageCollectorStub,
+ registerCollector: registerStub,
+ } as any;
+
+ const savedObjectsRepoStub = {
+ get: () => ({
+ attributes: {
+ 'ui_viewed.overview': 10,
+ 'ui_clicked.app_search': 2,
+ 'ui_clicked.workplace_search': 3,
+ },
+ }),
+ incrementCounter: jest.fn(),
+ };
+ const savedObjectsMock = {
+ createInternalRepository: jest.fn(() => savedObjectsRepoStub),
+ } as any;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('registerTelemetryUsageCollector', () => {
+ it('should make and register the usage collector', () => {
+ registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger);
+
+ expect(registerStub).toHaveBeenCalledTimes(1);
+ expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1);
+ expect(makeUsageCollectorStub.mock.calls[0][0].type).toBe('enterprise_search');
+ expect(makeUsageCollectorStub.mock.calls[0][0].isReady()).toBe(true);
+ });
+ });
+
+ describe('fetchTelemetryMetrics', () => {
+ it('should return existing saved objects data', async () => {
+ registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger);
+ const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch();
+
+ expect(savedObjectsCounts).toEqual({
+ ui_viewed: {
+ overview: 10,
+ },
+ ui_clicked: {
+ app_search: 2,
+ workplace_search: 3,
+ },
+ });
+ });
+
+ it('should return a default telemetry object if no saved data exists', async () => {
+ const emptySavedObjectsMock = {
+ createInternalRepository: () => ({
+ get: () => ({ attributes: null }),
+ }),
+ } as any;
+
+ registerTelemetryUsageCollector(usageCollectionMock, emptySavedObjectsMock, mockLogger);
+ const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch();
+
+ expect(savedObjectsCounts).toEqual({
+ ui_viewed: {
+ overview: 0,
+ },
+ ui_clicked: {
+ app_search: 0,
+ workplace_search: 0,
+ },
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/server/collectors/enterprise_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/enterprise_search/telemetry.ts
new file mode 100644
index 0000000000000..a124a185b9a34
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/collectors/enterprise_search/telemetry.ts
@@ -0,0 +1,87 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { get } from 'lodash';
+import { SavedObjectsServiceStart, Logger } from 'src/core/server';
+import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
+
+import { getSavedObjectAttributesFromRepo } from '../lib/telemetry';
+
+interface ITelemetry {
+ ui_viewed: {
+ overview: number;
+ };
+ ui_clicked: {
+ app_search: number;
+ workplace_search: number;
+ };
+}
+
+export const ES_TELEMETRY_NAME = 'enterprise_search_telemetry';
+
+/**
+ * Register the telemetry collector
+ */
+
+export const registerTelemetryUsageCollector = (
+ usageCollection: UsageCollectionSetup,
+ savedObjects: SavedObjectsServiceStart,
+ log: Logger
+) => {
+ const telemetryUsageCollector = usageCollection.makeUsageCollector({
+ type: 'enterprise_search',
+ fetch: async () => fetchTelemetryMetrics(savedObjects, log),
+ isReady: () => true,
+ schema: {
+ ui_viewed: {
+ overview: { type: 'long' },
+ },
+ ui_clicked: {
+ app_search: { type: 'long' },
+ workplace_search: { type: 'long' },
+ },
+ },
+ });
+ usageCollection.registerCollector(telemetryUsageCollector);
+};
+
+/**
+ * Fetch the aggregated telemetry metrics from our saved objects
+ */
+
+const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log: Logger) => {
+ const savedObjectsRepository = savedObjects.createInternalRepository();
+ const savedObjectAttributes = await getSavedObjectAttributesFromRepo(
+ ES_TELEMETRY_NAME,
+ savedObjectsRepository,
+ log
+ );
+
+ const defaultTelemetrySavedObject: ITelemetry = {
+ ui_viewed: {
+ overview: 0,
+ },
+ ui_clicked: {
+ app_search: 0,
+ workplace_search: 0,
+ },
+ };
+
+ // If we don't have an existing/saved telemetry object, return the default
+ if (!savedObjectAttributes) {
+ return defaultTelemetrySavedObject;
+ }
+
+ return {
+ ui_viewed: {
+ overview: get(savedObjectAttributes, 'ui_viewed.overview', 0),
+ },
+ ui_clicked: {
+ app_search: get(savedObjectAttributes, 'ui_clicked.app_search', 0),
+ workplace_search: get(savedObjectAttributes, 'ui_clicked.workplace_search', 0),
+ },
+ } as ITelemetry;
+};
diff --git a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts
index aae162c23ccb4..6cf0be9fd1f31 100644
--- a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts
+++ b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts
@@ -15,7 +15,7 @@ import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server';
import { getSavedObjectAttributesFromRepo, incrementUICounter } from './telemetry';
-describe('App Search Telemetry Usage Collector', () => {
+describe('Telemetry helpers', () => {
beforeEach(() => {
jest.clearAllMocks();
});
diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts
index 323f79e63bc6f..8e3ae2cfbeb86 100644
--- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts
+++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts
@@ -38,51 +38,63 @@ describe('callEnterpriseSearchConfigAPI', () => {
external_url: 'http://some.vanity.url/',
read_only_mode: false,
ilm_enabled: true,
+ is_federated_auth: false,
configured_limits: {
- max_document_byte_size: 102400,
- max_engines_per_meta_engine: 15,
+ app_search: {
+ engine: {
+ document_size_in_bytes: 102400,
+ source_engines_per_meta_engine: 15,
+ },
+ },
+ workplace_search: {
+ custom_api_source: {
+ document_size_in_bytes: 102400,
+ total_fields: 64,
+ },
+ },
+ },
+ },
+ current_user: {
+ name: 'someuser',
+ access: {
+ app_search: true,
+ workplace_search: false,
},
app_search: {
- account_id: 'some-id-string',
- onboarding_complete: true,
+ account: {
+ id: 'some-id-string',
+ onboarding_complete: true,
+ },
+ role: {
+ id: 'account_id:somestring|user_oid:somestring',
+ role_type: 'owner',
+ ability: {
+ access_all_engines: true,
+ destroy: ['session'],
+ manage: ['account_credentials', 'account_engines'], // etc
+ edit: ['LocoMoco::Account'], // etc
+ view: ['Engine'], // etc
+ credential_types: ['admin', 'private', 'search'],
+ available_role_types: ['owner', 'admin'],
+ },
+ },
},
workplace_search: {
- can_create_invitations: true,
- is_federated_auth: false,
organization: {
name: 'ACME Donuts',
default_org_name: 'My Organization',
},
- fp_account: {
+ account: {
id: 'some-id-string',
groups: ['Default', 'Cats'],
is_admin: true,
can_create_personal_sources: true,
+ can_create_invitations: true,
is_curated: false,
viewed_onboarding_page: true,
},
},
},
- current_user: {
- name: 'someuser',
- access: {
- app_search: true,
- workplace_search: false,
- },
- app_search_role: {
- id: 'account_id:somestring|user_oid:somestring',
- role_type: 'owner',
- ability: {
- access_all_engines: true,
- destroy: ['session'],
- manage: ['account_credentials', 'account_engines'], // etc
- edit: ['LocoMoco::Account'], // etc
- view: ['Engine'], // etc
- credential_types: ['admin', 'private', 'search'],
- available_role_types: ['owner', 'admin'],
- },
- },
- },
};
beforeEach(() => {
@@ -91,7 +103,7 @@ describe('callEnterpriseSearchConfigAPI', () => {
it('calls the config API endpoint', async () => {
fetchMock.mockImplementationOnce((url: string) => {
- expect(url).toEqual('http://localhost:3002/api/ent/v1/internal/client_config');
+ expect(url).toEqual('http://localhost:3002/api/ent/v2/internal/client_config');
return Promise.resolve(new Response(JSON.stringify(mockResponse)));
});
@@ -116,9 +128,20 @@ describe('callEnterpriseSearchConfigAPI', () => {
publicUrl: undefined,
readOnlyMode: false,
ilmEnabled: false,
+ isFederatedAuth: false,
configuredLimits: {
- maxDocumentByteSize: undefined,
- maxEnginesPerMetaEngine: undefined,
+ appSearch: {
+ engine: {
+ maxDocumentByteSize: undefined,
+ maxEnginesPerMetaEngine: undefined,
+ },
+ },
+ workplaceSearch: {
+ customApiSource: {
+ maxDocumentByteSize: undefined,
+ totalFields: undefined,
+ },
+ },
},
appSearch: {
accountId: undefined,
@@ -138,17 +161,16 @@ describe('callEnterpriseSearchConfigAPI', () => {
},
},
workplaceSearch: {
- canCreateInvitations: false,
- isFederatedAuth: false,
organization: {
name: undefined,
defaultOrgName: undefined,
},
- fpAccount: {
+ account: {
id: undefined,
groups: [],
isAdmin: false,
canCreatePersonalSources: false,
+ canCreateInvitations: false,
isCurated: false,
viewedOnboardingPage: false,
},
diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts
index c9cbec15169d9..10a75e59cb249 100644
--- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts
+++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts
@@ -29,7 +29,7 @@ interface IReturn extends IInitialAppData {
* useful various settings (e.g. product access, external URL)
* needed by the Kibana plugin at the setup stage
*/
-const ENDPOINT = '/api/ent/v1/internal/client_config';
+const ENDPOINT = '/api/ent/v2/internal/client_config';
export const callEnterpriseSearchConfigAPI = async ({
config,
@@ -67,44 +67,60 @@ export const callEnterpriseSearchConfigAPI = async ({
publicUrl: stripTrailingSlash(data?.settings?.external_url),
readOnlyMode: !!data?.settings?.read_only_mode,
ilmEnabled: !!data?.settings?.ilm_enabled,
+ isFederatedAuth: !!data?.settings?.is_federated_auth, // i.e., not standard auth
configuredLimits: {
- maxDocumentByteSize: data?.settings?.configured_limits?.max_document_byte_size,
- maxEnginesPerMetaEngine: data?.settings?.configured_limits?.max_engines_per_meta_engine,
+ appSearch: {
+ engine: {
+ maxDocumentByteSize:
+ data?.settings?.configured_limits?.app_search?.engine?.document_size_in_bytes,
+ maxEnginesPerMetaEngine:
+ data?.settings?.configured_limits?.app_search?.engine?.source_engines_per_meta_engine,
+ },
+ },
+ workplaceSearch: {
+ customApiSource: {
+ maxDocumentByteSize:
+ data?.settings?.configured_limits?.workplace_search?.custom_api_source
+ ?.document_size_in_bytes,
+ totalFields:
+ data?.settings?.configured_limits?.workplace_search?.custom_api_source?.total_fields,
+ },
+ },
},
appSearch: {
- accountId: data?.settings?.app_search?.account_id,
- onBoardingComplete: !!data?.settings?.app_search?.onboarding_complete,
+ accountId: data?.current_user?.app_search?.account?.id,
+ onBoardingComplete: !!data?.current_user?.app_search?.account?.onboarding_complete,
role: {
- id: data?.current_user?.app_search_role?.id,
- roleType: data?.current_user?.app_search_role?.role_type,
+ id: data?.current_user?.app_search?.role?.id,
+ roleType: data?.current_user?.app_search?.role?.role_type,
ability: {
- accessAllEngines: !!data?.current_user?.app_search_role?.ability?.access_all_engines,
- destroy: data?.current_user?.app_search_role?.ability?.destroy || [],
- manage: data?.current_user?.app_search_role?.ability?.manage || [],
- edit: data?.current_user?.app_search_role?.ability?.edit || [],
- view: data?.current_user?.app_search_role?.ability?.view || [],
- credentialTypes: data?.current_user?.app_search_role?.ability?.credential_types || [],
+ accessAllEngines: !!data?.current_user?.app_search?.role?.ability?.access_all_engines,
+ destroy: data?.current_user?.app_search?.role?.ability?.destroy || [],
+ manage: data?.current_user?.app_search?.role?.ability?.manage || [],
+ edit: data?.current_user?.app_search?.role?.ability?.edit || [],
+ view: data?.current_user?.app_search?.role?.ability?.view || [],
+ credentialTypes: data?.current_user?.app_search?.role?.ability?.credential_types || [],
availableRoleTypes:
- data?.current_user?.app_search_role?.ability?.available_role_types || [],
+ data?.current_user?.app_search?.role?.ability?.available_role_types || [],
},
},
},
workplaceSearch: {
- canCreateInvitations: !!data?.settings?.workplace_search?.can_create_invitations,
- isFederatedAuth: !!data?.settings?.workplace_search?.is_federated_auth,
organization: {
- name: data?.settings?.workplace_search?.organization?.name,
- defaultOrgName: data?.settings?.workplace_search?.organization?.default_org_name,
+ name: data?.current_user?.workplace_search?.organization?.name,
+ defaultOrgName: data?.current_user?.workplace_search?.organization?.default_org_name,
},
- fpAccount: {
- id: data?.settings?.workplace_search?.fp_account.id,
- groups: data?.settings?.workplace_search?.fp_account.groups || [],
- isAdmin: !!data?.settings?.workplace_search?.fp_account?.is_admin,
- canCreatePersonalSources: !!data?.settings?.workplace_search?.fp_account
+ account: {
+ id: data?.current_user?.workplace_search?.account?.id,
+ groups: data?.current_user?.workplace_search?.account?.groups || [],
+ isAdmin: !!data?.current_user?.workplace_search?.account?.is_admin,
+ canCreatePersonalSources: !!data?.current_user?.workplace_search?.account
?.can_create_personal_sources,
- isCurated: !!data?.settings?.workplace_search?.fp_account.is_curated,
- viewedOnboardingPage: !!data?.settings?.workplace_search?.fp_account
- .viewed_onboarding_page,
+ canCreateInvitations: !!data?.current_user?.workplace_search?.account
+ ?.can_create_invitations,
+ isCurated: !!data?.current_user?.workplace_search?.account?.is_curated,
+ viewedOnboardingPage: !!data?.current_user?.workplace_search?.account
+ ?.viewed_onboarding_page,
},
},
};
diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts
index 3f3f182433144..34f83ef3a3fd2 100644
--- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts
+++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts
@@ -5,6 +5,7 @@
*/
import { mockConfig, mockLogger } from '../__mocks__';
+import { JSON_HEADER } from '../../common/constants';
import { EnterpriseSearchRequestHandler } from './enterprise_search_request_handler';
@@ -150,18 +151,26 @@ describe('EnterpriseSearchRequestHandler', () => {
);
});
- it('returns an error when user authentication to Enterprise Search fails', async () => {
- EnterpriseSearchAPI.mockReturn({}, { url: 'http://localhost:3002/login' });
- const requestHandler = enterpriseSearchRequestHandler.createRequest({
- path: '/api/unauthenticated',
+ describe('user authentication errors', () => {
+ afterEach(async () => {
+ const requestHandler = enterpriseSearchRequestHandler.createRequest({
+ path: '/api/unauthenticated',
+ });
+ await makeAPICall(requestHandler);
+
+ EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/unauthenticated');
+ expect(responseMock.customError).toHaveBeenCalledWith({
+ body: 'Error connecting to Enterprise Search: Cannot authenticate Enterprise Search user',
+ statusCode: 502,
+ });
});
- await makeAPICall(requestHandler);
- EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/unauthenticated');
+ it('errors when redirected to /login', async () => {
+ EnterpriseSearchAPI.mockReturn({}, { url: 'http://localhost:3002/login' });
+ });
- expect(responseMock.customError).toHaveBeenCalledWith({
- body: 'Error connecting to Enterprise Search: Cannot authenticate Enterprise Search user',
- statusCode: 502,
+ it('errors when redirected to /ent/select', async () => {
+ EnterpriseSearchAPI.mockReturn({}, { url: 'http://localhost:3002/ent/select' });
});
});
});
@@ -185,7 +194,7 @@ const makeAPICall = (handler: Function, params = {}) => {
const EnterpriseSearchAPI = {
shouldHaveBeenCalledWith(expectedUrl: string, expectedParams = {}) {
expect(fetchMock).toHaveBeenCalledWith(expectedUrl, {
- headers: { Authorization: 'Basic 123' },
+ headers: { Authorization: 'Basic 123', ...JSON_HEADER },
method: 'GET',
body: undefined,
...expectedParams,
diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts
index 8f31bd9063d4a..18f10c590847c 100644
--- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts
+++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts
@@ -14,6 +14,7 @@ import {
Logger,
} from 'src/core/server';
import { ConfigType } from '../index';
+import { JSON_HEADER } from '../../common/constants';
interface IConstructorDependencies {
config: ConfigType;
@@ -25,7 +26,7 @@ interface IRequestParams {
hasValidData?: (body?: ResponseBody) => boolean;
}
export interface IEnterpriseSearchRequestHandler {
- createRequest(requestParams?: object): RequestHandler, unknown>;
+ createRequest(requestParams?: object): RequestHandler;
}
/**
@@ -52,12 +53,12 @@ export class EnterpriseSearchRequestHandler {
}: IRequestParams) {
return async (
_context: RequestHandlerContext,
- request: KibanaRequest, unknown>,
+ request: KibanaRequest,
response: KibanaResponseFactory
) => {
try {
// Set up API URL
- const queryParams = { ...request.query, ...params };
+ const queryParams = { ...(request.query as object), ...params };
const queryString = !this.isEmptyObj(queryParams)
? `?${querystring.stringify(queryParams)}`
: '';
@@ -65,7 +66,7 @@ export class EnterpriseSearchRequestHandler {
// Set up API options
const { method } = request.route;
- const headers = { Authorization: request.headers.authorization as string };
+ const headers = { Authorization: request.headers.authorization as string, ...JSON_HEADER };
const body = !this.isEmptyObj(request.body as object)
? JSON.stringify(request.body)
: undefined;
@@ -73,7 +74,7 @@ export class EnterpriseSearchRequestHandler {
// Call the Enterprise Search API and pass back response to the front-end
const apiResponse = await fetch(url, { method, headers, body });
- if (apiResponse.url.endsWith('/login')) {
+ if (apiResponse.url.endsWith('/login') || apiResponse.url.endsWith('/ent/select')) {
throw new Error('Cannot authenticate Enterprise Search user');
}
diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts
index 617210a544262..729a03d24065e 100644
--- a/x-pack/plugins/enterprise_search/server/plugin.ts
+++ b/x-pack/plugins/enterprise_search/server/plugin.ts
@@ -31,8 +31,10 @@ import {
IEnterpriseSearchRequestHandler,
} from './lib/enterprise_search_request_handler';
-import { registerConfigDataRoute } from './routes/enterprise_search/config_data';
+import { enterpriseSearchTelemetryType } from './saved_objects/enterprise_search/telemetry';
+import { registerTelemetryUsageCollector as registerESTelemetryUsageCollector } from './collectors/enterprise_search/telemetry';
import { registerTelemetryRoute } from './routes/enterprise_search/telemetry';
+import { registerConfigDataRoute } from './routes/enterprise_search/config_data';
import { appSearchTelemetryType } from './saved_objects/app_search/telemetry';
import { registerTelemetryUsageCollector as registerASTelemetryUsageCollector } from './collectors/app_search/telemetry';
@@ -81,8 +83,12 @@ export class EnterpriseSearchPlugin implements Plugin {
name: ENTERPRISE_SEARCH_PLUGIN.NAME,
order: 0,
icon: 'logoEnterpriseSearch',
- navLinkId: APP_SEARCH_PLUGIN.ID, // TODO - remove this once functional tests no longer rely on navLinkId
- app: ['kibana', APP_SEARCH_PLUGIN.ID, WORKPLACE_SEARCH_PLUGIN.ID],
+ app: [
+ 'kibana',
+ ENTERPRISE_SEARCH_PLUGIN.ID,
+ APP_SEARCH_PLUGIN.ID,
+ WORKPLACE_SEARCH_PLUGIN.ID,
+ ],
catalogue: [ENTERPRISE_SEARCH_PLUGIN.ID, APP_SEARCH_PLUGIN.ID, WORKPLACE_SEARCH_PLUGIN.ID],
privileges: null,
});
@@ -94,14 +100,16 @@ export class EnterpriseSearchPlugin implements Plugin {
const dependencies = { config, security, request, log };
const { hasAppSearchAccess, hasWorkplaceSearchAccess } = await checkAccess(dependencies);
+ const showEnterpriseSearchOverview = hasAppSearchAccess || hasWorkplaceSearchAccess;
return {
navLinks: {
+ enterpriseSearch: showEnterpriseSearchOverview,
appSearch: hasAppSearchAccess,
workplaceSearch: hasWorkplaceSearchAccess,
},
catalogue: {
- enterpriseSearch: hasAppSearchAccess || hasWorkplaceSearchAccess,
+ enterpriseSearch: showEnterpriseSearchOverview,
appSearch: hasAppSearchAccess,
workplaceSearch: hasWorkplaceSearchAccess,
},
@@ -123,6 +131,7 @@ export class EnterpriseSearchPlugin implements Plugin {
/**
* Bootstrap the routes, saved objects, and collector for telemetry
*/
+ savedObjects.registerType(enterpriseSearchTelemetryType);
savedObjects.registerType(appSearchTelemetryType);
savedObjects.registerType(workplaceSearchTelemetryType);
let savedObjectsStarted: SavedObjectsServiceStart;
@@ -131,6 +140,7 @@ export class EnterpriseSearchPlugin implements Plugin {
savedObjectsStarted = coreStart.savedObjects;
if (usageCollection) {
+ registerESTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger);
registerASTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger);
registerWSTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger);
}
diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts
index 7ed1d7b17753c..bfc07c8b64ef5 100644
--- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts
+++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts
@@ -9,12 +9,13 @@ import { schema } from '@kbn/config-schema';
import { IRouteDependencies } from '../../plugin';
import { incrementUICounter } from '../../collectors/lib/telemetry';
+import { ES_TELEMETRY_NAME } from '../../collectors/enterprise_search/telemetry';
import { AS_TELEMETRY_NAME } from '../../collectors/app_search/telemetry';
import { WS_TELEMETRY_NAME } from '../../collectors/workplace_search/telemetry';
const productToTelemetryMap = {
+ enterprise_search: ES_TELEMETRY_NAME,
app_search: AS_TELEMETRY_NAME,
workplace_search: WS_TELEMETRY_NAME,
- enterprise_search: 'TODO',
};
export function registerTelemetryRoute({
diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/enterprise_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/enterprise_search/telemetry.ts
new file mode 100644
index 0000000000000..54044e67939da
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/saved_objects/enterprise_search/telemetry.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.
+ */
+/* istanbul ignore file */
+
+import { SavedObjectsType } from 'src/core/server';
+import { ES_TELEMETRY_NAME } from '../../collectors/enterprise_search/telemetry';
+
+export const enterpriseSearchTelemetryType: SavedObjectsType = {
+ name: ES_TELEMETRY_NAME,
+ hidden: false,
+ namespaceType: 'agnostic',
+ mappings: {
+ dynamic: false,
+ properties: {},
+ },
+};
diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap
index e4014cf49778c..63a59d59d6d07 100644
--- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap
+++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap
@@ -111,6 +111,7 @@ Array [
"visualization",
"timelion-sheet",
"canvas-workpad",
+ "lens",
"map",
"dashboard",
"query",
diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts
index e37c7491de5dc..4122c590e74b1 100644
--- a/x-pack/plugins/features/server/oss_features.ts
+++ b/x-pack/plugins/features/server/oss_features.ts
@@ -172,6 +172,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
'visualization',
'timelion-sheet',
'canvas-workpad',
+ 'lens',
'map',
'dashboard',
'query',
diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts
index a9f6d2ea03bdf..6882ddea4ad5d 100644
--- a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts
+++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts
@@ -24,7 +24,7 @@ export interface DataTypeDefinition {
export interface ParameterDefinition {
title?: string;
description?: JSX.Element | string;
- fieldConfig: FieldConfig;
+ fieldConfig: FieldConfig;
schema?: any;
props?: { [key: string]: ParameterDefinition };
documentation?: {
diff --git a/x-pack/plugins/infra/common/http_api/index.ts b/x-pack/plugins/infra/common/http_api/index.ts
index 818009417fb1c..4c729d11ba8c1 100644
--- a/x-pack/plugins/infra/common/http_api/index.ts
+++ b/x-pack/plugins/infra/common/http_api/index.ts
@@ -10,3 +10,4 @@ export * from './log_entries';
export * from './metrics_explorer';
export * from './metrics_api';
export * from './log_alerts';
+export * from './snapshot_api';
diff --git a/x-pack/plugins/infra/common/http_api/metrics_api.ts b/x-pack/plugins/infra/common/http_api/metrics_api.ts
index 7436566f039ca..41657fdce2153 100644
--- a/x-pack/plugins/infra/common/http_api/metrics_api.ts
+++ b/x-pack/plugins/infra/common/http_api/metrics_api.ts
@@ -33,7 +33,6 @@ export const MetricsAPIRequestRT = rt.intersection([
afterKey: rt.union([rt.null, afterKeyObjectRT]),
limit: rt.union([rt.number, rt.null, rt.undefined]),
filters: rt.array(rt.object),
- forceInterval: rt.boolean,
dropLastBucket: rt.boolean,
alignDataToEnd: rt.boolean,
}),
@@ -59,7 +58,10 @@ export const MetricsAPIRowRT = rt.intersection([
rt.type({
timestamp: rt.number,
}),
- rt.record(rt.string, rt.union([rt.string, rt.number, rt.null, rt.undefined])),
+ rt.record(
+ rt.string,
+ rt.union([rt.string, rt.number, rt.null, rt.undefined, rt.array(rt.object)])
+ ),
]);
export const MetricsAPISeriesRT = rt.intersection([
diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer.ts
index c5776e0b0ced1..460b2bf9d802e 100644
--- a/x-pack/plugins/infra/common/http_api/metrics_explorer.ts
+++ b/x-pack/plugins/infra/common/http_api/metrics_explorer.ts
@@ -89,7 +89,10 @@ export const metricsExplorerRowRT = rt.intersection([
rt.type({
timestamp: rt.number,
}),
- rt.record(rt.string, rt.union([rt.string, rt.number, rt.null, rt.undefined])),
+ rt.record(
+ rt.string,
+ rt.union([rt.string, rt.number, rt.null, rt.undefined, rt.array(rt.object)])
+ ),
]);
export const metricsExplorerSeriesRT = rt.intersection([
diff --git a/x-pack/plugins/infra/common/http_api/snapshot_api.ts b/x-pack/plugins/infra/common/http_api/snapshot_api.ts
index 11cb57238f917..e1b8dfa4770ba 100644
--- a/x-pack/plugins/infra/common/http_api/snapshot_api.ts
+++ b/x-pack/plugins/infra/common/http_api/snapshot_api.ts
@@ -6,7 +6,7 @@
import * as rt from 'io-ts';
import { SnapshotMetricTypeRT, ItemTypeRT } from '../inventory_models/types';
-import { metricsExplorerSeriesRT } from './metrics_explorer';
+import { MetricsAPISeriesRT } from './metrics_api';
export const SnapshotNodePathRT = rt.intersection([
rt.type({
@@ -22,7 +22,7 @@ const SnapshotNodeMetricOptionalRT = rt.partial({
value: rt.union([rt.number, rt.null]),
avg: rt.union([rt.number, rt.null]),
max: rt.union([rt.number, rt.null]),
- timeseries: metricsExplorerSeriesRT,
+ timeseries: MetricsAPISeriesRT,
});
const SnapshotNodeMetricRequiredRT = rt.type({
@@ -36,6 +36,7 @@ export const SnapshotNodeMetricRT = rt.intersection([
export const SnapshotNodeRT = rt.type({
metrics: rt.array(SnapshotNodeMetricRT),
path: rt.array(SnapshotNodePathRT),
+ name: rt.string,
});
export const SnapshotNodeResponseRT = rt.type({
diff --git a/x-pack/plugins/infra/common/inventory_models/types.ts b/x-pack/plugins/infra/common/inventory_models/types.ts
index 570220bbc7aa5..851646ef1fa12 100644
--- a/x-pack/plugins/infra/common/inventory_models/types.ts
+++ b/x-pack/plugins/infra/common/inventory_models/types.ts
@@ -281,6 +281,10 @@ export const ESSumBucketAggRT = rt.type({
}),
});
+export const ESTopHitsAggRT = rt.type({
+ top_hits: rt.object,
+});
+
interface SnapshotTermsWithAggregation {
terms: { field: string };
aggregations: MetricsUIAggregation;
@@ -304,6 +308,7 @@ export const ESAggregationRT = rt.union([
ESSumBucketAggRT,
ESTermsWithAggregationRT,
ESCaridnalityAggRT,
+ ESTopHitsAggRT,
]);
export const MetricsUIAggregationRT = rt.record(rt.string, ESAggregationRT);
diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx
index d2c30a4f38ee9..e01ca3ab6e844 100644
--- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx
+++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx
@@ -88,6 +88,7 @@ describe('ConditionalToolTip', () => {
mockedUseSnapshot.mockReturnValue({
nodes: [
{
+ name: 'host-01',
path: [{ label: 'host-01', value: 'host-01', ip: '192.168.1.10' }],
metrics: [
{ name: 'cpu', value: 0.1, avg: 0.4, max: 0.7 },
diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/calculate_bounds_from_nodes.test.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/calculate_bounds_from_nodes.test.ts
index fbb6aa933219a..49f4b56532936 100644
--- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/calculate_bounds_from_nodes.test.ts
+++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/calculate_bounds_from_nodes.test.ts
@@ -7,6 +7,7 @@ import { calculateBoundsFromNodes } from './calculate_bounds_from_nodes';
import { SnapshotNode } from '../../../../../common/http_api/snapshot_api';
const nodes: SnapshotNode[] = [
{
+ name: 'host-01',
path: [{ value: 'host-01', label: 'host-01' }],
metrics: [
{
@@ -18,6 +19,7 @@ const nodes: SnapshotNode[] = [
],
},
{
+ name: 'host-02',
path: [{ value: 'host-02', label: 'host-02' }],
metrics: [
{
diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/sort_nodes.test.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/sort_nodes.test.ts
index 2a9f8b911c124..f7d9f029f00df 100644
--- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/sort_nodes.test.ts
+++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/sort_nodes.test.ts
@@ -9,6 +9,7 @@ import { SnapshotNode } from '../../../../../common/http_api/snapshot_api';
const nodes: SnapshotNode[] = [
{
+ name: 'host-01',
path: [{ value: 'host-01', label: 'host-01' }],
metrics: [
{
@@ -20,6 +21,7 @@ const nodes: SnapshotNode[] = [
],
},
{
+ name: 'host-02',
path: [{ value: 'host-02', label: 'host-02' }],
metrics: [
{
diff --git a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts
index 939498305eb98..c5b667fb20538 100644
--- a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts
+++ b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts
@@ -8,12 +8,12 @@
import { timeMilliseconds } from 'd3-time';
import * as runtimeTypes from 'io-ts';
-import { compact, first, get, has } from 'lodash';
+import { compact, first } from 'lodash';
import { pipe } from 'fp-ts/lib/pipeable';
import { map, fold } from 'fp-ts/lib/Either';
import { identity, constant } from 'fp-ts/lib/function';
import { RequestHandlerContext } from 'src/core/server';
-import { JsonObject, JsonValue } from '../../../../common/typed_json';
+import { JsonValue } from '../../../../common/typed_json';
import {
LogEntriesAdapter,
LogEntriesParams,
@@ -31,7 +31,7 @@ const TIMESTAMP_FORMAT = 'epoch_millis';
interface LogItemHit {
_index: string;
_id: string;
- _source: JsonObject;
+ fields: { [key: string]: [value: unknown] };
sort: [number, number];
}
@@ -82,7 +82,8 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter {
body: {
size: typeof size !== 'undefined' ? size : LOG_ENTRIES_PAGE_SIZE,
track_total_hits: false,
- _source: fields,
+ _source: false,
+ fields,
query: {
bool: {
filter: [
@@ -214,6 +215,8 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter {
values: [id],
},
},
+ fields: ['*'],
+ _source: false,
},
};
@@ -230,8 +233,8 @@ function mapHitsToLogEntryDocuments(hits: SortedSearchHit[], fields: string[]):
return hits.map((hit) => {
const logFields = fields.reduce<{ [fieldName: string]: JsonValue }>(
(flattenedFields, field) => {
- if (has(hit._source, field)) {
- flattenedFields[field] = get(hit._source, field);
+ if (field in hit.fields) {
+ flattenedFields[field] = hit.fields[field][0];
}
return flattenedFields;
},
diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts
index 2f3593a11f664..d6592719d0723 100644
--- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts
+++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts
@@ -16,12 +16,11 @@ import {
} from '../../adapters/framework/adapter_types';
import { Comparator, InventoryMetricConditions } from './types';
import { AlertServices } from '../../../../../alerts/server';
-import { InfraSnapshot } from '../../snapshot';
-import { parseFilterQuery } from '../../../utils/serialized_query';
import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types';
-import { InfraTimerangeInput } from '../../../../common/http_api/snapshot_api';
-import { InfraSourceConfiguration } from '../../sources';
+import { InfraTimerangeInput, SnapshotRequest } from '../../../../common/http_api/snapshot_api';
+import { InfraSource } from '../../sources';
import { UNGROUPED_FACTORY_KEY } from '../common/utils';
+import { getNodes } from '../../../routes/snapshot/lib/get_nodes';
type ConditionResult = InventoryMetricConditions & {
shouldFire: boolean[];
@@ -33,7 +32,7 @@ type ConditionResult = InventoryMetricConditions & {
export const evaluateCondition = async (
condition: InventoryMetricConditions,
nodeType: InventoryItemType,
- sourceConfiguration: InfraSourceConfiguration,
+ source: InfraSource,
callCluster: AlertServices['callCluster'],
filterQuery?: string,
lookbackSize?: number
@@ -55,7 +54,7 @@ export const evaluateCondition = async (
nodeType,
metric,
timerange,
- sourceConfiguration,
+ source,
filterQuery,
customMetric
);
@@ -94,12 +93,11 @@ const getData = async (
nodeType: InventoryItemType,
metric: SnapshotMetricType,
timerange: InfraTimerangeInput,
- sourceConfiguration: InfraSourceConfiguration,
+ source: InfraSource,
filterQuery?: string,
customMetric?: SnapshotCustomMetricInput
) => {
- const snapshot = new InfraSnapshot();
- const esClient = (
+ const client = (
options: CallWithRequestParams
): Promise> => callCluster('search', options);
@@ -107,17 +105,17 @@ const getData = async (
metric === 'custom' ? (customMetric as SnapshotCustomMetricInput) : { type: metric },
];
- const options = {
- filterQuery: parseFilterQuery(filterQuery),
+ const snapshotRequest: SnapshotRequest = {
+ filterQuery,
nodeType,
groupBy: [],
- sourceConfiguration,
+ sourceId: 'default',
metrics,
timerange,
includeTimeseries: Boolean(timerange.lookbackSize),
};
try {
- const { nodes } = await snapshot.getNodes(esClient, options);
+ const { nodes } = await getNodes(client, snapshotRequest, source);
if (!nodes.length) return { [UNGROUPED_FACTORY_KEY]: null }; // No Data state
diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts
index bdac9dcd1dee8..99904f15b4606 100644
--- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts
+++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts
@@ -50,9 +50,7 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) =
);
const results = await Promise.all(
- criteria.map((c) =>
- evaluateCondition(c, nodeType, source.configuration, services.callCluster, filterQuery)
- )
+ criteria.map((c) => evaluateCondition(c, nodeType, source, services.callCluster, filterQuery))
);
const inventoryItems = Object.keys(first(results)!);
diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts
index 755c395818f5a..2ab015b6b37a2 100644
--- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts
+++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts
@@ -26,7 +26,7 @@ interface InventoryMetricThresholdParams {
interface PreviewInventoryMetricThresholdAlertParams {
callCluster: ILegacyScopedClusterClient['callAsCurrentUser'];
params: InventoryMetricThresholdParams;
- config: InfraSource['configuration'];
+ source: InfraSource;
lookback: Unit;
alertInterval: string;
}
@@ -34,7 +34,7 @@ interface PreviewInventoryMetricThresholdAlertParams {
export const previewInventoryMetricThresholdAlert = async ({
callCluster,
params,
- config,
+ source,
lookback,
alertInterval,
}: PreviewInventoryMetricThresholdAlertParams) => {
@@ -55,7 +55,7 @@ export const previewInventoryMetricThresholdAlert = async ({
try {
const results = await Promise.all(
criteria.map((c) =>
- evaluateCondition(c, nodeType, config, callCluster, filterQuery, lookbackSize)
+ evaluateCondition(c, nodeType, source, callCluster, filterQuery, lookbackSize)
)
);
diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts
index 078ca46d42e60..8696081043ff7 100644
--- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts
+++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts
@@ -8,8 +8,8 @@ import { networkTraffic } from '../../../../../common/inventory_models/shared/me
import { MetricExpressionParams, Aggregators } from '../types';
import { getIntervalInSeconds } from '../../../../utils/get_interval_in_seconds';
import { roundTimestamp } from '../../../../utils/round_timestamp';
-import { getDateHistogramOffset } from '../../../snapshot/query_helpers';
import { createPercentileAggregation } from './create_percentile_aggregation';
+import { calculateDateHistogramOffset } from '../../../metrics/lib/calculate_date_histogram_offset';
const MINIMUM_BUCKETS = 5;
@@ -46,7 +46,7 @@ export const getElasticsearchMetricQuery = (
timeUnit
);
- const offset = getDateHistogramOffset(from, interval);
+ const offset = calculateDateHistogramOffset({ from, to, interval, field: timefield });
const aggregations =
aggType === Aggregators.COUNT
diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/convert_document_source_to_log_item_fields.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/convert_document_source_to_log_item_fields.ts
index 099e7c3b5038c..7c8560d72ff97 100644
--- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/convert_document_source_to_log_item_fields.ts
+++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/convert_document_source_to_log_item_fields.ts
@@ -20,6 +20,11 @@ const serializeValue = (value: any): string => {
}
return `${value}`;
};
+export const convertESFieldsToLogItemFields = (fields: {
+ [field: string]: [value: unknown];
+}): LogEntriesItemField[] => {
+ return Object.keys(fields).map((field) => ({ field, value: serializeValue(fields[field][0]) }));
+};
export const convertDocumentSourceToLogItemFields = (
source: JsonObject,
diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts
index 9b3e31f4da87a..e211f72b4e076 100644
--- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts
+++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts
@@ -22,7 +22,7 @@ import {
SavedSourceConfigurationFieldColumnRuntimeType,
} from '../../sources';
import { getBuiltinRules } from './builtin_rules';
-import { convertDocumentSourceToLogItemFields } from './convert_document_source_to_log_item_fields';
+import { convertESFieldsToLogItemFields } from './convert_document_source_to_log_item_fields';
import {
CompiledLogMessageFormattingRule,
Fields,
@@ -264,7 +264,7 @@ export class InfraLogEntriesDomain {
tiebreaker: document.sort[1],
},
fields: sortBy(
- [...defaultFields, ...convertDocumentSourceToLogItemFields(document._source)],
+ [...defaultFields, ...convertESFieldsToLogItemFields(document.fields)],
'field'
),
};
@@ -313,7 +313,7 @@ export class InfraLogEntriesDomain {
interface LogItemHit {
_index: string;
_id: string;
- _source: JsonObject;
+ fields: { [field: string]: [value: unknown] };
sort: [number, number];
}
diff --git a/x-pack/plugins/infra/server/lib/infra_types.ts b/x-pack/plugins/infra/server/lib/infra_types.ts
index 9896ad6ac1cd1..084ece52302b0 100644
--- a/x-pack/plugins/infra/server/lib/infra_types.ts
+++ b/x-pack/plugins/infra/server/lib/infra_types.ts
@@ -8,7 +8,6 @@ import { InfraSourceConfiguration } from '../../common/graphql/types';
import { InfraFieldsDomain } from './domains/fields_domain';
import { InfraLogEntriesDomain } from './domains/log_entries_domain';
import { InfraMetricsDomain } from './domains/metrics_domain';
-import { InfraSnapshot } from './snapshot';
import { InfraSources } from './sources';
import { InfraSourceStatus } from './source_status';
import { InfraConfig } from '../plugin';
@@ -30,7 +29,6 @@ export interface InfraDomainLibs {
export interface InfraBackendLibs extends InfraDomainLibs {
configuration: InfraConfig;
framework: KibanaFramework;
- snapshot: InfraSnapshot;
sources: InfraSources;
sourceStatus: InfraSourceStatus;
}
diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/create_aggregations.test.ts.snap b/x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/create_aggregations.test.ts.snap
index d2d90914eced5..2cbbc623aed38 100644
--- a/x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/create_aggregations.test.ts.snap
+++ b/x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/create_aggregations.test.ts.snap
@@ -53,7 +53,6 @@ Object {
"groupBy0": Object {
"terms": Object {
"field": "host.name",
- "order": "asc",
},
},
},
diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.ts b/x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.ts
index 95e6ece215133..90e584368e9ad 100644
--- a/x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.ts
+++ b/x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.ts
@@ -5,6 +5,7 @@
*/
import { get, values, first } from 'lodash';
+import * as rt from 'io-ts';
import {
MetricsAPIRequest,
MetricsAPISeries,
@@ -13,15 +14,20 @@ import {
} from '../../../../common/http_api/metrics_api';
import {
HistogramBucket,
- MetricValueType,
BasicMetricValueRT,
NormalizedMetricValueRT,
PercentilesTypeRT,
PercentilesKeyedTypeRT,
+ TopHitsTypeRT,
+ MetricValueTypeRT,
} from '../types';
+
const BASE_COLUMNS = [{ name: 'timestamp', type: 'date' }] as MetricsAPIColumn[];
-const getValue = (valueObject: string | number | MetricValueType) => {
+const ValueObjectTypeRT = rt.union([rt.string, rt.number, MetricValueTypeRT]);
+type ValueObjectType = rt.TypeOf;
+
+const getValue = (valueObject: ValueObjectType) => {
if (NormalizedMetricValueRT.is(valueObject)) {
return valueObject.normalized_value || valueObject.value;
}
@@ -50,6 +56,10 @@ const getValue = (valueObject: string | number | MetricValueType) => {
return valueObject.value;
}
+ if (TopHitsTypeRT.is(valueObject)) {
+ return valueObject.hits.hits.map((hit) => hit._source);
+ }
+
return null;
};
@@ -61,8 +71,8 @@ const convertBucketsToRows = (
const ids = options.metrics.map((metric) => metric.id);
const metrics = ids.reduce((acc, id) => {
const valueObject = get(bucket, [id]);
- return { ...acc, [id]: getValue(valueObject) };
- }, {} as Record);
+ return { ...acc, [id]: ValueObjectTypeRT.is(valueObject) ? getValue(valueObject) : null };
+ }, {} as Record);
return { timestamp: bucket.key as number, ...metrics };
});
};
diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/create_aggregations.ts b/x-pack/plugins/infra/server/lib/metrics/lib/create_aggregations.ts
index 991e5febfc634..63fdbb3d2b30f 100644
--- a/x-pack/plugins/infra/server/lib/metrics/lib/create_aggregations.ts
+++ b/x-pack/plugins/infra/server/lib/metrics/lib/create_aggregations.ts
@@ -33,7 +33,7 @@ export const createAggregations = (options: MetricsAPIRequest) => {
composite: {
size: limit,
sources: options.groupBy.map((field, index) => ({
- [`groupBy${index}`]: { terms: { field, order: 'asc' } },
+ [`groupBy${index}`]: { terms: { field } },
})),
},
aggs: histogramAggregation,
diff --git a/x-pack/plugins/infra/server/lib/metrics/types.ts b/x-pack/plugins/infra/server/lib/metrics/types.ts
index d1866470e0cf9..8746614b559d6 100644
--- a/x-pack/plugins/infra/server/lib/metrics/types.ts
+++ b/x-pack/plugins/infra/server/lib/metrics/types.ts
@@ -25,17 +25,51 @@ export const PercentilesKeyedTypeRT = rt.type({
values: rt.array(rt.type({ key: rt.string, value: NumberOrNullRT })),
});
+export const TopHitsTypeRT = rt.type({
+ hits: rt.type({
+ total: rt.type({
+ value: rt.number,
+ relation: rt.string,
+ }),
+ hits: rt.array(
+ rt.intersection([
+ rt.type({
+ _index: rt.string,
+ _id: rt.string,
+ _score: NumberOrNullRT,
+ _source: rt.object,
+ }),
+ rt.partial({
+ sort: rt.array(rt.union([rt.string, rt.number])),
+ max_score: NumberOrNullRT,
+ }),
+ ])
+ ),
+ }),
+});
+
export const MetricValueTypeRT = rt.union([
BasicMetricValueRT,
NormalizedMetricValueRT,
PercentilesTypeRT,
PercentilesKeyedTypeRT,
+ TopHitsTypeRT,
]);
export type MetricValueType = rt.TypeOf;
+export const TermsWithMetrics = rt.intersection([
+ rt.type({
+ buckets: rt.array(rt.record(rt.string, rt.union([rt.number, rt.string, MetricValueTypeRT]))),
+ }),
+ rt.partial({
+ sum_other_doc_count: rt.number,
+ doc_count_error_upper_bound: rt.number,
+ }),
+]);
+
export const HistogramBucketRT = rt.record(
rt.string,
- rt.union([rt.number, rt.string, MetricValueTypeRT])
+ rt.union([rt.number, rt.string, MetricValueTypeRT, TermsWithMetrics])
);
export const HistogramResponseRT = rt.type({
diff --git a/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts b/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts
deleted file mode 100644
index ca63043ba868e..0000000000000
--- a/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { i18n } from '@kbn/i18n';
-import { findInventoryModel, findInventoryFields } from '../../../common/inventory_models/index';
-import { InfraSnapshotRequestOptions } from './types';
-import { getIntervalInSeconds } from '../../utils/get_interval_in_seconds';
-import {
- MetricsUIAggregation,
- MetricsUIAggregationRT,
- InventoryItemType,
-} from '../../../common/inventory_models/types';
-import {
- SnapshotMetricInput,
- SnapshotCustomMetricInputRT,
-} from '../../../common/http_api/snapshot_api';
-import { networkTraffic } from '../../../common/inventory_models/shared/metrics/snapshot/network_traffic';
-
-interface GroupBySource {
- [id: string]: {
- terms: {
- field: string | null | undefined;
- missing_bucket?: boolean;
- };
- };
-}
-
-export const getFieldByNodeType = (options: InfraSnapshotRequestOptions) => {
- const inventoryFields = findInventoryFields(options.nodeType, options.sourceConfiguration.fields);
- return inventoryFields.id;
-};
-
-export const getGroupedNodesSources = (options: InfraSnapshotRequestOptions) => {
- const fields = findInventoryFields(options.nodeType, options.sourceConfiguration.fields);
- const sources: GroupBySource[] = options.groupBy.map((gb) => {
- return { [`${gb.field}`]: { terms: { field: gb.field } } };
- });
- sources.push({
- id: {
- terms: { field: fields.id },
- },
- });
- sources.push({
- name: { terms: { field: fields.name, missing_bucket: true } },
- });
- return sources;
-};
-
-export const getMetricsSources = (options: InfraSnapshotRequestOptions) => {
- const fields = findInventoryFields(options.nodeType, options.sourceConfiguration.fields);
- return [{ id: { terms: { field: fields.id } } }];
-};
-
-export const metricToAggregation = (
- nodeType: InventoryItemType,
- metric: SnapshotMetricInput,
- index: number
-) => {
- const inventoryModel = findInventoryModel(nodeType);
- if (SnapshotCustomMetricInputRT.is(metric)) {
- if (metric.aggregation === 'rate') {
- return networkTraffic(`custom_${index}`, metric.field);
- }
- return {
- [`custom_${index}`]: {
- [metric.aggregation]: {
- field: metric.field,
- },
- },
- };
- }
- return inventoryModel.metrics.snapshot?.[metric.type];
-};
-
-export const getMetricsAggregations = (
- options: InfraSnapshotRequestOptions
-): MetricsUIAggregation => {
- const { metrics } = options;
- return metrics.reduce((aggs, metric, index) => {
- const aggregation = metricToAggregation(options.nodeType, metric, index);
- if (!MetricsUIAggregationRT.is(aggregation)) {
- throw new Error(
- i18n.translate('xpack.infra.snapshot.missingSnapshotMetricError', {
- defaultMessage: 'The aggregation for {metric} for {nodeType} is not available.',
- values: {
- nodeType: options.nodeType,
- metric: metric.type,
- },
- })
- );
- }
- return { ...aggs, ...aggregation };
- }, {});
-};
-
-export const getDateHistogramOffset = (from: number, interval: string): string => {
- const fromInSeconds = Math.floor(from / 1000);
- const bucketSizeInSeconds = getIntervalInSeconds(interval);
-
- // negative offset to align buckets with full intervals (e.g. minutes)
- const offset = (fromInSeconds % bucketSizeInSeconds) - bucketSizeInSeconds;
- return `${offset}s`;
-};
diff --git a/x-pack/plugins/infra/server/lib/snapshot/response_helpers.test.ts b/x-pack/plugins/infra/server/lib/snapshot/response_helpers.test.ts
deleted file mode 100644
index 74840afc157d2..0000000000000
--- a/x-pack/plugins/infra/server/lib/snapshot/response_helpers.test.ts
+++ /dev/null
@@ -1,119 +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 {
- isIPv4,
- getIPFromBucket,
- InfraSnapshotNodeGroupByBucket,
- getMetricValueFromBucket,
- InfraSnapshotMetricsBucket,
-} from './response_helpers';
-
-describe('InfraOps ResponseHelpers', () => {
- describe('isIPv4', () => {
- it('should return true for IPv4', () => {
- expect(isIPv4('192.168.2.4')).toBe(true);
- });
- it('should return false for anything else', () => {
- expect(isIPv4('0:0:0:0:0:0:0:1')).toBe(false);
- });
- });
-
- describe('getIPFromBucket', () => {
- it('should return IPv4 address', () => {
- const bucket: InfraSnapshotNodeGroupByBucket = {
- key: {
- id: 'example-01',
- name: 'example-01',
- },
- ip: {
- hits: {
- total: { value: 1 },
- hits: [
- {
- _index: 'metricbeat-2019-01-01',
- _type: '_doc',
- _id: '29392939',
- _score: null,
- sort: [],
- _source: {
- host: {
- ip: ['2001:db8:85a3::8a2e:370:7334', '192.168.1.4'],
- },
- },
- },
- ],
- },
- },
- };
- expect(getIPFromBucket('host', bucket)).toBe('192.168.1.4');
- });
- it('should NOT return ipv6 address', () => {
- const bucket: InfraSnapshotNodeGroupByBucket = {
- key: {
- id: 'example-01',
- name: 'example-01',
- },
- ip: {
- hits: {
- total: { value: 1 },
- hits: [
- {
- _index: 'metricbeat-2019-01-01',
- _type: '_doc',
- _id: '29392939',
- _score: null,
- sort: [],
- _source: {
- host: {
- ip: ['2001:db8:85a3::8a2e:370:7334'],
- },
- },
- },
- ],
- },
- },
- };
- expect(getIPFromBucket('host', bucket)).toBe(null);
- });
- });
-
- describe('getMetricValueFromBucket', () => {
- it('should return the value of a bucket with data', () => {
- expect(getMetricValueFromBucket('custom', testBucket, 1)).toBe(0.5);
- });
- it('should return the normalized value of a bucket with data', () => {
- expect(getMetricValueFromBucket('cpu', testNormalizedBucket, 1)).toBe(50);
- });
- it('should return null for a bucket with no data', () => {
- expect(getMetricValueFromBucket('custom', testEmptyBucket, 1)).toBe(null);
- });
- });
-});
-
-// Hack to get around TypeScript
-const buckets = [
- {
- key: 'a',
- doc_count: 1,
- custom_1: {
- value: 0.5,
- },
- },
- {
- key: 'b',
- doc_count: 1,
- cpu: {
- value: 0.5,
- normalized_value: 50,
- },
- },
- {
- key: 'c',
- doc_count: 0,
- },
-] as InfraSnapshotMetricsBucket[];
-const [testBucket, testNormalizedBucket, testEmptyBucket] = buckets;
diff --git a/x-pack/plugins/infra/server/lib/snapshot/response_helpers.ts b/x-pack/plugins/infra/server/lib/snapshot/response_helpers.ts
deleted file mode 100644
index 2652e362b7eff..0000000000000
--- a/x-pack/plugins/infra/server/lib/snapshot/response_helpers.ts
+++ /dev/null
@@ -1,208 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { isNumber, last, max, sum, get } from 'lodash';
-import moment from 'moment';
-
-import { MetricsExplorerSeries } from '../../../common/http_api/metrics_explorer';
-import { getIntervalInSeconds } from '../../utils/get_interval_in_seconds';
-import { InfraSnapshotRequestOptions } from './types';
-import { findInventoryModel } from '../../../common/inventory_models';
-import { InventoryItemType, SnapshotMetricType } from '../../../common/inventory_models/types';
-import { SnapshotNodeMetric, SnapshotNodePath } from '../../../common/http_api/snapshot_api';
-
-export interface InfraSnapshotNodeMetricsBucket {
- key: { id: string };
- histogram: {
- buckets: InfraSnapshotMetricsBucket[];
- };
-}
-
-// Jumping through TypeScript hoops here:
-// We need an interface that has the known members 'key' and 'doc_count' and also
-// an unknown number of members with unknown names but known format, containing the
-// metrics.
-// This union type is the only way I found to express this that TypeScript accepts.
-export interface InfraSnapshotBucketWithKey {
- key: string | number;
- doc_count: number;
-}
-
-export interface InfraSnapshotBucketWithValues {
- [name: string]: { value: number; normalized_value?: number };
-}
-
-export type InfraSnapshotMetricsBucket = InfraSnapshotBucketWithKey & InfraSnapshotBucketWithValues;
-
-interface InfraSnapshotIpHit {
- _index: string;
- _type: string;
- _id: string;
- _score: number | null;
- _source: {
- host: {
- ip: string[] | string;
- };
- };
- sort: number[];
-}
-
-export interface InfraSnapshotNodeGroupByBucket {
- key: {
- id: string;
- name: string;
- [groupByField: string]: string;
- };
- ip: {
- hits: {
- total: { value: number };
- hits: InfraSnapshotIpHit[];
- };
- };
-}
-
-export const isIPv4 = (subject: string) => /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(subject);
-
-export const getIPFromBucket = (
- nodeType: InventoryItemType,
- bucket: InfraSnapshotNodeGroupByBucket
-): string | null => {
- const inventoryModel = findInventoryModel(nodeType);
- if (!inventoryModel.fields.ip) {
- return null;
- }
- const ip = get(bucket, `ip.hits.hits[0]._source.${inventoryModel.fields.ip}`, null) as
- | string[]
- | null;
- if (Array.isArray(ip)) {
- return ip.find(isIPv4) || null;
- } else if (typeof ip === 'string') {
- return ip;
- }
-
- return null;
-};
-
-export const getNodePath = (
- groupBucket: InfraSnapshotNodeGroupByBucket,
- options: InfraSnapshotRequestOptions
-): SnapshotNodePath[] => {
- const node = groupBucket.key;
- const path = options.groupBy.map((gb) => {
- return { value: node[`${gb.field}`], label: node[`${gb.field}`] } as SnapshotNodePath;
- });
- const ip = getIPFromBucket(options.nodeType, groupBucket);
- path.push({ value: node.id, label: node.name || node.id, ip });
- return path;
-};
-
-interface NodeMetricsForLookup {
- [nodeId: string]: InfraSnapshotMetricsBucket[];
-}
-
-export const getNodeMetricsForLookup = (
- metrics: InfraSnapshotNodeMetricsBucket[]
-): NodeMetricsForLookup => {
- return metrics.reduce((acc: NodeMetricsForLookup, metric) => {
- acc[`${metric.key.id}`] = metric.histogram.buckets;
- return acc;
- }, {});
-};
-
-// In the returned object,
-// value contains the value from the last bucket spanning a full interval
-// max and avg are calculated from all buckets returned for the timerange
-export const getNodeMetrics = (
- nodeBuckets: InfraSnapshotMetricsBucket[],
- options: InfraSnapshotRequestOptions
-): SnapshotNodeMetric[] => {
- if (!nodeBuckets) {
- return options.metrics.map((metric) => ({
- name: metric.type,
- value: null,
- max: null,
- avg: null,
- }));
- }
- const lastBucket = findLastFullBucket(nodeBuckets, options);
- if (!lastBucket) return [];
- return options.metrics.map((metric, index) => {
- const metricResult: SnapshotNodeMetric = {
- name: metric.type,
- value: getMetricValueFromBucket(metric.type, lastBucket, index),
- max: calculateMax(nodeBuckets, metric.type, index),
- avg: calculateAvg(nodeBuckets, metric.type, index),
- };
- if (options.includeTimeseries) {
- metricResult.timeseries = getTimeseriesData(nodeBuckets, metric.type, index);
- }
- return metricResult;
- });
-};
-
-const findLastFullBucket = (
- buckets: InfraSnapshotMetricsBucket[],
- options: InfraSnapshotRequestOptions
-) => {
- const to = moment.utc(options.timerange.to);
- const bucketSize = getIntervalInSeconds(options.timerange.interval);
- return buckets.reduce((current, item) => {
- const itemKey = isNumber(item.key) ? item.key : parseInt(item.key, 10);
- const date = moment.utc(itemKey + bucketSize * 1000);
- if (!date.isAfter(to) && item.doc_count > 0) {
- return item;
- }
- return current;
- }, last(buckets));
-};
-
-export const getMetricValueFromBucket = (
- type: SnapshotMetricType,
- bucket: InfraSnapshotMetricsBucket,
- index: number
-) => {
- const key = type === 'custom' ? `custom_${index}` : type;
- const metric = bucket[key];
- const value = metric && (metric.normalized_value || metric.value);
- return isFinite(value) ? value : null;
-};
-
-function calculateMax(
- buckets: InfraSnapshotMetricsBucket[],
- type: SnapshotMetricType,
- index: number
-) {
- return max(buckets.map((bucket) => getMetricValueFromBucket(type, bucket, index))) || 0;
-}
-
-function calculateAvg(
- buckets: InfraSnapshotMetricsBucket[],
- type: SnapshotMetricType,
- index: number
-) {
- return (
- sum(buckets.map((bucket) => getMetricValueFromBucket(type, bucket, index))) / buckets.length ||
- 0
- );
-}
-
-function getTimeseriesData(
- buckets: InfraSnapshotMetricsBucket[],
- type: SnapshotMetricType,
- index: number
-): MetricsExplorerSeries {
- return {
- id: type,
- columns: [
- { name: 'timestamp', type: 'date' },
- { name: 'metric_0', type: 'number' },
- ],
- rows: buckets.map((bucket) => ({
- timestamp: bucket.key as number,
- metric_0: getMetricValueFromBucket(type, bucket, index),
- })),
- };
-}
diff --git a/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts b/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts
deleted file mode 100644
index 33d8e738a717e..0000000000000
--- a/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts
+++ /dev/null
@@ -1,238 +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 { InfraDatabaseSearchResponse, CallWithRequestParams } from '../adapters/framework';
-
-import { JsonObject } from '../../../common/typed_json';
-import { SNAPSHOT_COMPOSITE_REQUEST_SIZE } from './constants';
-import {
- getGroupedNodesSources,
- getMetricsAggregations,
- getMetricsSources,
- getDateHistogramOffset,
-} from './query_helpers';
-import {
- getNodeMetrics,
- getNodeMetricsForLookup,
- getNodePath,
- InfraSnapshotNodeGroupByBucket,
- InfraSnapshotNodeMetricsBucket,
-} from './response_helpers';
-import { getAllCompositeData } from '../../utils/get_all_composite_data';
-import { createAfterKeyHandler } from '../../utils/create_afterkey_handler';
-import { findInventoryModel } from '../../../common/inventory_models';
-import { InfraSnapshotRequestOptions } from './types';
-import { createTimeRangeWithInterval } from './create_timerange_with_interval';
-import { SnapshotNode } from '../../../common/http_api/snapshot_api';
-
-type NamedSnapshotNode = SnapshotNode & { name: string };
-
-export type ESSearchClient = (
- options: CallWithRequestParams
-) => Promise>;
-export class InfraSnapshot {
- public async getNodes(
- client: ESSearchClient,
- options: InfraSnapshotRequestOptions
- ): Promise<{ nodes: NamedSnapshotNode[]; interval: string }> {
- // Both requestGroupedNodes and requestNodeMetrics may send several requests to elasticsearch
- // in order to page through the results of their respective composite aggregations.
- // Both chains of requests are supposed to run in parallel, and their results be merged
- // when they have both been completed.
- const timeRangeWithIntervalApplied = await createTimeRangeWithInterval(client, options);
- const optionsWithTimerange = { ...options, timerange: timeRangeWithIntervalApplied };
-
- const groupedNodesPromise = requestGroupedNodes(client, optionsWithTimerange);
- const nodeMetricsPromise = requestNodeMetrics(client, optionsWithTimerange);
- const [groupedNodeBuckets, nodeMetricBuckets] = await Promise.all([
- groupedNodesPromise,
- nodeMetricsPromise,
- ]);
- return {
- nodes: mergeNodeBuckets(groupedNodeBuckets, nodeMetricBuckets, options),
- interval: timeRangeWithIntervalApplied.interval,
- };
- }
-}
-
-const bucketSelector = (
- response: InfraDatabaseSearchResponse<{}, InfraSnapshotAggregationResponse>
-) => (response.aggregations && response.aggregations.nodes.buckets) || [];
-
-const handleAfterKey = createAfterKeyHandler(
- 'body.aggregations.nodes.composite.after',
- (input) => input?.aggregations?.nodes?.after_key
-);
-
-const callClusterFactory = (search: ESSearchClient) => (opts: any) =>
- search<{}, InfraSnapshotAggregationResponse>(opts);
-
-const requestGroupedNodes = async (
- client: ESSearchClient,
- options: InfraSnapshotRequestOptions
-): Promise => {
- const inventoryModel = findInventoryModel(options.nodeType);
- const query = {
- allowNoIndices: true,
- index: `${options.sourceConfiguration.logAlias},${options.sourceConfiguration.metricAlias}`,
- ignoreUnavailable: true,
- body: {
- query: {
- bool: {
- filter: buildFilters(options),
- },
- },
- size: 0,
- aggregations: {
- nodes: {
- composite: {
- size: options.overrideCompositeSize || SNAPSHOT_COMPOSITE_REQUEST_SIZE,
- sources: getGroupedNodesSources(options),
- },
- aggs: {
- ip: {
- top_hits: {
- sort: [{ [options.sourceConfiguration.fields.timestamp]: { order: 'desc' } }],
- _source: {
- includes: inventoryModel.fields.ip ? [inventoryModel.fields.ip] : [],
- },
- size: 1,
- },
- },
- },
- },
- },
- },
- };
- return getAllCompositeData(
- callClusterFactory(client),
- query,
- bucketSelector,
- handleAfterKey
- );
-};
-
-const calculateIndexPatterBasedOnMetrics = (options: InfraSnapshotRequestOptions) => {
- const { metrics } = options;
- if (metrics.every((m) => m.type === 'logRate')) {
- return options.sourceConfiguration.logAlias;
- }
- if (metrics.some((m) => m.type === 'logRate')) {
- return `${options.sourceConfiguration.logAlias},${options.sourceConfiguration.metricAlias}`;
- }
- return options.sourceConfiguration.metricAlias;
-};
-
-const requestNodeMetrics = async (
- client: ESSearchClient,
- options: InfraSnapshotRequestOptions
-): Promise => {
- const index = calculateIndexPatterBasedOnMetrics(options);
- const query = {
- allowNoIndices: true,
- index,
- ignoreUnavailable: true,
- body: {
- query: {
- bool: {
- filter: buildFilters(options, false),
- },
- },
- size: 0,
- aggregations: {
- nodes: {
- composite: {
- size: options.overrideCompositeSize || SNAPSHOT_COMPOSITE_REQUEST_SIZE,
- sources: getMetricsSources(options),
- },
- aggregations: {
- histogram: {
- date_histogram: {
- field: options.sourceConfiguration.fields.timestamp,
- interval: options.timerange.interval || '1m',
- offset: getDateHistogramOffset(options.timerange.from, options.timerange.interval),
- extended_bounds: {
- min: options.timerange.from,
- max: options.timerange.to,
- },
- },
- aggregations: getMetricsAggregations(options),
- },
- },
- },
- },
- },
- };
- return getAllCompositeData(
- callClusterFactory(client),
- query,
- bucketSelector,
- handleAfterKey
- );
-};
-
-// buckets can be InfraSnapshotNodeGroupByBucket[] or InfraSnapshotNodeMetricsBucket[]
-// but typing this in a way that makes TypeScript happy is unreadable (if possible at all)
-interface InfraSnapshotAggregationResponse {
- nodes: {
- buckets: any[];
- after_key: { [id: string]: string };
- };
-}
-
-const mergeNodeBuckets = (
- nodeGroupByBuckets: InfraSnapshotNodeGroupByBucket[],
- nodeMetricsBuckets: InfraSnapshotNodeMetricsBucket[],
- options: InfraSnapshotRequestOptions
-): NamedSnapshotNode[] => {
- const nodeMetricsForLookup = getNodeMetricsForLookup(nodeMetricsBuckets);
-
- return nodeGroupByBuckets.map((node) => {
- return {
- name: node.key.name || node.key.id, // For type safety; name can be derived from getNodePath but not in a TS-friendly way
- path: getNodePath(node, options),
- metrics: getNodeMetrics(nodeMetricsForLookup[node.key.id], options),
- };
- });
-};
-
-const createQueryFilterClauses = (filterQuery: JsonObject | undefined) =>
- filterQuery ? [filterQuery] : [];
-
-const buildFilters = (options: InfraSnapshotRequestOptions, withQuery = true) => {
- let filters: any = [
- {
- range: {
- [options.sourceConfiguration.fields.timestamp]: {
- gte: options.timerange.from,
- lte: options.timerange.to,
- format: 'epoch_millis',
- },
- },
- },
- ];
-
- if (withQuery) {
- filters = [...createQueryFilterClauses(options.filterQuery), ...filters];
- }
-
- if (options.accountId) {
- filters.push({
- term: {
- 'cloud.account.id': options.accountId,
- },
- });
- }
-
- if (options.region) {
- filters.push({
- term: {
- 'cloud.region': options.region,
- },
- });
- }
-
- return filters;
-};
diff --git a/x-pack/plugins/infra/server/lib/snapshot/types.ts b/x-pack/plugins/infra/server/lib/snapshot/types.ts
deleted file mode 100644
index 7e17cb91c6a59..0000000000000
--- a/x-pack/plugins/infra/server/lib/snapshot/types.ts
+++ /dev/null
@@ -1,15 +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 { JsonObject } from '../../../common/typed_json';
-import { InfraSourceConfiguration } from '../../../common/graphql/types';
-import { SnapshotRequest } from '../../../common/http_api/snapshot_api';
-
-export interface InfraSnapshotRequestOptions
- extends Omit {
- sourceConfiguration: InfraSourceConfiguration;
- filterQuery: JsonObject | undefined;
-}
diff --git a/x-pack/plugins/infra/server/lib/sources/has_data.ts b/x-pack/plugins/infra/server/lib/sources/has_data.ts
index 79b1375059dcb..53297640e541d 100644
--- a/x-pack/plugins/infra/server/lib/sources/has_data.ts
+++ b/x-pack/plugins/infra/server/lib/sources/has_data.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { ESSearchClient } from '../snapshot';
+import { ESSearchClient } from '../metrics/types';
export const hasData = async (index: string, client: ESSearchClient) => {
const params = {
diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts
index 51f91d7189db7..90b73b9a7585a 100644
--- a/x-pack/plugins/infra/server/plugin.ts
+++ b/x-pack/plugins/infra/server/plugin.ts
@@ -19,7 +19,6 @@ import { InfraElasticsearchSourceStatusAdapter } from './lib/adapters/source_sta
import { InfraFieldsDomain } from './lib/domains/fields_domain';
import { InfraLogEntriesDomain } from './lib/domains/log_entries_domain';
import { InfraMetricsDomain } from './lib/domains/metrics_domain';
-import { InfraSnapshot } from './lib/snapshot';
import { InfraSourceStatus } from './lib/source_status';
import { InfraSources } from './lib/sources';
import { InfraServerPluginDeps } from './lib/adapters/framework';
@@ -105,7 +104,6 @@ export class InfraServerPlugin {
sources,
}
);
- const snapshot = new InfraSnapshot();
// register saved object types
core.savedObjects.registerType(infraSourceConfigurationSavedObjectType);
@@ -129,7 +127,6 @@ export class InfraServerPlugin {
this.libs = {
configuration: this.config,
framework,
- snapshot,
sources,
sourceStatus,
...domainLibs,
diff --git a/x-pack/plugins/infra/server/routes/alerting/preview.ts b/x-pack/plugins/infra/server/routes/alerting/preview.ts
index 5594323d706de..40d09dadfe050 100644
--- a/x-pack/plugins/infra/server/routes/alerting/preview.ts
+++ b/x-pack/plugins/infra/server/routes/alerting/preview.ts
@@ -82,7 +82,7 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs)
callCluster,
params: { criteria, filterQuery, nodeType },
lookback,
- config: source.configuration,
+ source,
alertInterval,
});
diff --git a/x-pack/plugins/infra/server/routes/log_entries/entries.ts b/x-pack/plugins/infra/server/routes/log_entries/entries.ts
index 2cd889d9c5568..c1f63d9c29577 100644
--- a/x-pack/plugins/infra/server/routes/log_entries/entries.ts
+++ b/x-pack/plugins/infra/server/routes/log_entries/entries.ts
@@ -4,14 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import Boom from 'boom';
-
-import { pipe } from 'fp-ts/lib/pipeable';
-import { fold } from 'fp-ts/lib/Either';
-import { identity } from 'fp-ts/lib/function';
-import { schema } from '@kbn/config-schema';
-
-import { throwErrors } from '../../../common/runtime_types';
+import { createValidationFunction } from '../../../common/runtime_types';
import { InfraBackendLibs } from '../../lib/infra_types';
import {
@@ -22,22 +15,16 @@ import {
import { parseFilterQuery } from '../../utils/serialized_query';
import { LogEntriesParams } from '../../lib/domains/log_entries_domain';
-const escapeHatch = schema.object({}, { unknowns: 'allow' });
-
export const initLogEntriesRoute = ({ framework, logEntries }: InfraBackendLibs) => {
framework.registerRoute(
{
method: 'post',
path: LOG_ENTRIES_PATH,
- validate: { body: escapeHatch },
+ validate: { body: createValidationFunction(logEntriesRequestRT) },
},
async (requestContext, request, response) => {
try {
- const payload = pipe(
- logEntriesRequestRT.decode(request.body),
- fold(throwErrors(Boom.badRequest), identity)
- );
-
+ const payload = request.body;
const {
startTimestamp: startTimestamp,
endTimestamp: endTimestamp,
diff --git a/x-pack/plugins/infra/server/routes/log_entries/item.ts b/x-pack/plugins/infra/server/routes/log_entries/item.ts
index 85dba8f598a89..67ca481ff4fcb 100644
--- a/x-pack/plugins/infra/server/routes/log_entries/item.ts
+++ b/x-pack/plugins/infra/server/routes/log_entries/item.ts
@@ -4,14 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import Boom from 'boom';
-
-import { pipe } from 'fp-ts/lib/pipeable';
-import { fold } from 'fp-ts/lib/Either';
-import { identity } from 'fp-ts/lib/function';
-import { schema } from '@kbn/config-schema';
-
-import { throwErrors } from '../../../common/runtime_types';
+import { createValidationFunction } from '../../../common/runtime_types';
import { InfraBackendLibs } from '../../lib/infra_types';
import {
@@ -20,22 +13,16 @@ import {
logEntriesItemResponseRT,
} from '../../../common/http_api';
-const escapeHatch = schema.object({}, { unknowns: 'allow' });
-
export const initLogEntriesItemRoute = ({ framework, sources, logEntries }: InfraBackendLibs) => {
framework.registerRoute(
{
method: 'post',
path: LOG_ENTRIES_ITEM_PATH,
- validate: { body: escapeHatch },
+ validate: { body: createValidationFunction(logEntriesItemRequestRT) },
},
async (requestContext, request, response) => {
try {
- const payload = pipe(
- logEntriesItemRequestRT.decode(request.body),
- fold(throwErrors(Boom.badRequest), identity)
- );
-
+ const payload = request.body;
const { id, sourceId } = payload;
const sourceConfiguration = (
await sources.getSourceConfiguration(requestContext.core.savedObjects.client, sourceId)
diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/find_interval_for_metrics.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/find_interval_for_metrics.ts
index 876bbb4199441..8ab0f4a44c85d 100644
--- a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/find_interval_for_metrics.ts
+++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/find_interval_for_metrics.ts
@@ -7,9 +7,9 @@
import { uniq } from 'lodash';
import LRU from 'lru-cache';
import { MetricsExplorerRequestBody } from '../../../../common/http_api';
-import { ESSearchClient } from '../../../lib/snapshot';
import { getDatasetForField } from './get_dataset_for_field';
import { calculateMetricInterval } from '../../../utils/calculate_metric_interval';
+import { ESSearchClient } from '../../../lib/metrics/types';
const cache = new LRU({
max: 100,
diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_dataset_for_field.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_dataset_for_field.ts
index 94e91d32b14bb..85bb5b106c87c 100644
--- a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_dataset_for_field.ts
+++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_dataset_for_field.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { ESSearchClient } from '../../../lib/snapshot';
+import { ESSearchClient } from '../../../lib/metrics/types';
interface EventDatasetHit {
_source: {
diff --git a/x-pack/plugins/infra/server/routes/snapshot/index.ts b/x-pack/plugins/infra/server/routes/snapshot/index.ts
index 00bc1e74ea871..3f09ae89bc97e 100644
--- a/x-pack/plugins/infra/server/routes/snapshot/index.ts
+++ b/x-pack/plugins/infra/server/routes/snapshot/index.ts
@@ -10,10 +10,10 @@ import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { InfraBackendLibs } from '../../lib/infra_types';
import { UsageCollector } from '../../usage/usage_collector';
-import { parseFilterQuery } from '../../utils/serialized_query';
import { SnapshotRequestRT, SnapshotNodeResponseRT } from '../../../common/http_api/snapshot_api';
import { throwErrors } from '../../../common/runtime_types';
import { createSearchClient } from '../../lib/create_search_client';
+import { getNodes } from './lib/get_nodes';
const escapeHatch = schema.object({}, { unknowns: 'allow' });
@@ -30,43 +30,22 @@ export const initSnapshotRoute = (libs: InfraBackendLibs) => {
},
async (requestContext, request, response) => {
try {
- const {
- filterQuery,
- nodeType,
- groupBy,
- sourceId,
- metrics,
- timerange,
- accountId,
- region,
- includeTimeseries,
- overrideCompositeSize,
- } = pipe(
+ const snapshotRequest = pipe(
SnapshotRequestRT.decode(request.body),
fold(throwErrors(Boom.badRequest), identity)
);
+
const source = await libs.sources.getSourceConfiguration(
requestContext.core.savedObjects.client,
- sourceId
+ snapshotRequest.sourceId
);
- UsageCollector.countNode(nodeType);
- const options = {
- filterQuery: parseFilterQuery(filterQuery),
- accountId,
- region,
- nodeType,
- groupBy,
- sourceConfiguration: source.configuration,
- metrics,
- timerange,
- includeTimeseries,
- overrideCompositeSize,
- };
+ UsageCollector.countNode(snapshotRequest.nodeType);
const client = createSearchClient(requestContext, framework);
- const nodesWithInterval = await libs.snapshot.getNodes(client, options);
+ const snapshotResponse = await getNodes(client, snapshotRequest, source);
+
return response.ok({
- body: SnapshotNodeResponseRT.encode(nodesWithInterval),
+ body: SnapshotNodeResponseRT.encode(snapshotResponse),
});
} catch (error) {
return response.internalError({
diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/apply_metadata_to_last_path.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/apply_metadata_to_last_path.ts
new file mode 100644
index 0000000000000..f41d76bbc156f
--- /dev/null
+++ b/x-pack/plugins/infra/server/routes/snapshot/lib/apply_metadata_to_last_path.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 { get, last, first, isArray } from 'lodash';
+import { findInventoryFields } from '../../../../common/inventory_models';
+import {
+ SnapshotRequest,
+ SnapshotNodePath,
+ SnapshotNode,
+ MetricsAPISeries,
+ MetricsAPIRow,
+} from '../../../../common/http_api';
+import { META_KEY } from './constants';
+import { InfraSource } from '../../../lib/sources';
+
+export const isIPv4 = (subject: string) => /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(subject);
+
+type RowWithMetadata = MetricsAPIRow & {
+ [META_KEY]: object[];
+};
+
+export const applyMetadataToLastPath = (
+ series: MetricsAPISeries,
+ node: SnapshotNode,
+ snapshotRequest: SnapshotRequest,
+ source: InfraSource
+): SnapshotNodePath[] => {
+ // First we need to find a row with metadata
+ const rowWithMeta = series.rows.find(
+ (row) => (row[META_KEY] && isArray(row[META_KEY]) && (row[META_KEY] as object[]).length) || 0
+ ) as RowWithMetadata | undefined;
+
+ if (rowWithMeta) {
+ // We need just the first doc, there should only be one
+ const firstMetaDoc = first(rowWithMeta[META_KEY]);
+ // We also need the last path to add the metadata to
+ const lastPath = last(node.path);
+ if (firstMetaDoc && lastPath) {
+ // We will need the inventory fields so we can use the field paths to get
+ // the values from the metadata document
+ const inventoryFields = findInventoryFields(
+ snapshotRequest.nodeType,
+ source.configuration.fields
+ );
+ // Set the label as the name and fallback to the id OR path.value
+ lastPath.label = get(firstMetaDoc, inventoryFields.name, lastPath.value);
+ // If the inventory fields contain an ip address, we need to try and set that
+ // on the path object. IP addersses are typically stored as multiple fields. We will
+ // use the first IPV4 address we find.
+ if (inventoryFields.ip) {
+ const ipAddresses = get(firstMetaDoc, inventoryFields.ip) as string[];
+ if (Array.isArray(ipAddresses)) {
+ lastPath.ip = ipAddresses.find(isIPv4) || null;
+ } else if (typeof ipAddresses === 'string') {
+ lastPath.ip = ipAddresses;
+ }
+ }
+ return [...node.path.slice(0, node.path.length - 1), lastPath];
+ }
+ }
+ return node.path;
+};
diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/calculate_index_pattern_based_on_metrics.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/calculate_index_pattern_based_on_metrics.ts
new file mode 100644
index 0000000000000..4218aecfe74a8
--- /dev/null
+++ b/x-pack/plugins/infra/server/routes/snapshot/lib/calculate_index_pattern_based_on_metrics.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { SnapshotRequest } from '../../../../common/http_api';
+import { InfraSource } from '../../../lib/sources';
+
+export const calculateIndexPatterBasedOnMetrics = (
+ options: SnapshotRequest,
+ source: InfraSource
+) => {
+ const { metrics } = options;
+ if (metrics.every((m) => m.type === 'logRate')) {
+ return source.configuration.logAlias;
+ }
+ if (metrics.some((m) => m.type === 'logRate')) {
+ return `${source.configuration.logAlias},${source.configuration.metricAlias}`;
+ }
+ return source.configuration.metricAlias;
+};
diff --git a/x-pack/plugins/infra/server/lib/snapshot/index.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/constants.ts
similarity index 85%
rename from x-pack/plugins/infra/server/lib/snapshot/index.ts
rename to x-pack/plugins/infra/server/routes/snapshot/lib/constants.ts
index 8db54da803648..563c720224435 100644
--- a/x-pack/plugins/infra/server/lib/snapshot/index.ts
+++ b/x-pack/plugins/infra/server/routes/snapshot/lib/constants.ts
@@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export * from './snapshot';
+export const META_KEY = '__metadata__';
diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/copy_missing_metrics.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/copy_missing_metrics.ts
new file mode 100644
index 0000000000000..36397862e4153
--- /dev/null
+++ b/x-pack/plugins/infra/server/routes/snapshot/lib/copy_missing_metrics.ts
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { memoize, last, first } from 'lodash';
+import { SnapshotNode, SnapshotNodeResponse } from '../../../../common/http_api';
+
+const createMissingMetricFinder = (nodes: SnapshotNode[]) =>
+ memoize((id: string) => {
+ const nodeWithMetrics = nodes.find((node) => {
+ const lastPath = last(node.path);
+ const metric = first(node.metrics);
+ return lastPath && metric && lastPath.value === id && metric.value !== null;
+ });
+ if (nodeWithMetrics) {
+ return nodeWithMetrics.metrics;
+ }
+ });
+
+/**
+ * This function will look for nodes with missing data and try to find a node to copy the data from.
+ * This functionality exists to suppor the use case where the user requests a group by on "Service type".
+ * Since that grouping naturally excludeds every metric (except the metric for the service.type), we still
+ * want to display the node with a value. A good example is viewing hosts by CPU Usage and grouping by service
+ * Without this every service but `system` would be null.
+ */
+export const copyMissingMetrics = (response: SnapshotNodeResponse) => {
+ const { nodes } = response;
+ const find = createMissingMetricFinder(nodes);
+ const newNodes = nodes.map((node) => {
+ const lastPath = last(node.path);
+ const metric = first(node.metrics);
+ const allRowsNull = metric?.timeseries?.rows.every((r) => r.metric_0 == null) ?? true;
+ if (lastPath && metric && metric.value === null && allRowsNull) {
+ const newMetrics = find(lastPath.value);
+ if (newMetrics) {
+ return { ...node, metrics: newMetrics };
+ }
+ }
+ return node;
+ });
+ return { ...response, nodes: newNodes };
+};
diff --git a/x-pack/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/create_timerange_with_interval.ts
similarity index 80%
rename from x-pack/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts
rename to x-pack/plugins/infra/server/routes/snapshot/lib/create_timerange_with_interval.ts
index 719ffdb8fa7c4..827e0901c1c01 100644
--- a/x-pack/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts
+++ b/x-pack/plugins/infra/server/routes/snapshot/lib/create_timerange_with_interval.ts
@@ -5,14 +5,16 @@
*/
import { uniq } from 'lodash';
-import { InfraSnapshotRequestOptions } from './types';
-import { getMetricsAggregations } from './query_helpers';
-import { calculateMetricInterval } from '../../utils/calculate_metric_interval';
-import { MetricsUIAggregation, ESBasicMetricAggRT } from '../../../common/inventory_models/types';
-import { getDatasetForField } from '../../routes/metrics_explorer/lib/get_dataset_for_field';
-import { InfraTimerangeInput } from '../../../common/http_api/snapshot_api';
-import { ESSearchClient } from '.';
-import { getIntervalInSeconds } from '../../utils/get_interval_in_seconds';
+import { InfraTimerangeInput } from '../../../../common/http_api';
+import { ESSearchClient } from '../../../lib/metrics/types';
+import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds';
+import { calculateMetricInterval } from '../../../utils/calculate_metric_interval';
+import { getMetricsAggregations, InfraSnapshotRequestOptions } from './get_metrics_aggregations';
+import {
+ MetricsUIAggregation,
+ ESBasicMetricAggRT,
+} from '../../../../common/inventory_models/types';
+import { getDatasetForField } from '../../metrics_explorer/lib/get_dataset_for_field';
const createInterval = async (client: ESSearchClient, options: InfraSnapshotRequestOptions) => {
const { timerange } = options;
diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/get_metrics_aggregations.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/get_metrics_aggregations.ts
new file mode 100644
index 0000000000000..2421469eb1bdd
--- /dev/null
+++ b/x-pack/plugins/infra/server/routes/snapshot/lib/get_metrics_aggregations.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 { JsonObject } from '../../../../common/typed_json';
+import {
+ InventoryItemType,
+ MetricsUIAggregation,
+ MetricsUIAggregationRT,
+} from '../../../../common/inventory_models/types';
+import {
+ SnapshotMetricInput,
+ SnapshotCustomMetricInputRT,
+ SnapshotRequest,
+} from '../../../../common/http_api';
+import { findInventoryModel } from '../../../../common/inventory_models';
+import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic';
+import { InfraSourceConfiguration } from '../../../lib/sources';
+
+export interface InfraSnapshotRequestOptions
+ extends Omit {
+ sourceConfiguration: InfraSourceConfiguration;
+ filterQuery: JsonObject | undefined;
+}
+
+export const metricToAggregation = (
+ nodeType: InventoryItemType,
+ metric: SnapshotMetricInput,
+ index: number
+) => {
+ const inventoryModel = findInventoryModel(nodeType);
+ if (SnapshotCustomMetricInputRT.is(metric)) {
+ if (metric.aggregation === 'rate') {
+ return networkTraffic(`custom_${index}`, metric.field);
+ }
+ return {
+ [`custom_${index}`]: {
+ [metric.aggregation]: {
+ field: metric.field,
+ },
+ },
+ };
+ }
+ return inventoryModel.metrics.snapshot?.[metric.type];
+};
+
+export const getMetricsAggregations = (
+ options: InfraSnapshotRequestOptions
+): MetricsUIAggregation => {
+ const { metrics } = options;
+ return metrics.reduce((aggs, metric, index) => {
+ const aggregation = metricToAggregation(options.nodeType, metric, index);
+ if (!MetricsUIAggregationRT.is(aggregation)) {
+ throw new Error(
+ i18n.translate('xpack.infra.snapshot.missingSnapshotMetricError', {
+ defaultMessage: 'The aggregation for {metric} for {nodeType} is not available.',
+ values: {
+ nodeType: options.nodeType,
+ metric: metric.type,
+ },
+ })
+ );
+ }
+ return { ...aggs, ...aggregation };
+ }, {});
+};
diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts
new file mode 100644
index 0000000000000..9332d5aee1f52
--- /dev/null
+++ b/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.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 { SnapshotRequest } from '../../../../common/http_api';
+import { ESSearchClient } from '../../../lib/metrics/types';
+import { InfraSource } from '../../../lib/sources';
+import { transformRequestToMetricsAPIRequest } from './transform_request_to_metrics_api_request';
+import { queryAllData } from './query_all_data';
+import { transformMetricsApiResponseToSnapshotResponse } from './trasform_metrics_ui_response';
+import { copyMissingMetrics } from './copy_missing_metrics';
+
+export const getNodes = async (
+ client: ESSearchClient,
+ snapshotRequest: SnapshotRequest,
+ source: InfraSource
+) => {
+ const metricsApiRequest = await transformRequestToMetricsAPIRequest(
+ client,
+ source,
+ snapshotRequest
+ );
+ const metricsApiResponse = await queryAllData(client, metricsApiRequest);
+ return copyMissingMetrics(
+ transformMetricsApiResponseToSnapshotResponse(
+ metricsApiRequest,
+ snapshotRequest,
+ source,
+ metricsApiResponse
+ )
+ );
+};
diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/query_all_data.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/query_all_data.ts
new file mode 100644
index 0000000000000..a9d2352cf55b7
--- /dev/null
+++ b/x-pack/plugins/infra/server/routes/snapshot/lib/query_all_data.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { MetricsAPIRequest, MetricsAPIResponse } from '../../../../common/http_api';
+import { ESSearchClient } from '../../../lib/metrics/types';
+import { query } from '../../../lib/metrics';
+
+const handleResponse = (
+ client: ESSearchClient,
+ options: MetricsAPIRequest,
+ previousResponse?: MetricsAPIResponse
+) => async (resp: MetricsAPIResponse): Promise => {
+ const combinedResponse = previousResponse
+ ? {
+ ...previousResponse,
+ series: [...previousResponse.series, ...resp.series],
+ info: resp.info,
+ }
+ : resp;
+ if (resp.info.afterKey) {
+ return query(client, { ...options, afterKey: resp.info.afterKey }).then(
+ handleResponse(client, options, combinedResponse)
+ );
+ }
+ return combinedResponse;
+};
+
+export const queryAllData = (client: ESSearchClient, options: MetricsAPIRequest) => {
+ return query(client, options).then(handleResponse(client, options));
+};
diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts
new file mode 100644
index 0000000000000..700f4ef39bb66
--- /dev/null
+++ b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts
@@ -0,0 +1,84 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { findInventoryFields } from '../../../../common/inventory_models';
+import { MetricsAPIRequest, SnapshotRequest } from '../../../../common/http_api';
+import { ESSearchClient } from '../../../lib/metrics/types';
+import { InfraSource } from '../../../lib/sources';
+import { createTimeRangeWithInterval } from './create_timerange_with_interval';
+import { parseFilterQuery } from '../../../utils/serialized_query';
+import { transformSnapshotMetricsToMetricsAPIMetrics } from './transform_snapshot_metrics_to_metrics_api_metrics';
+import { calculateIndexPatterBasedOnMetrics } from './calculate_index_pattern_based_on_metrics';
+import { META_KEY } from './constants';
+
+export const transformRequestToMetricsAPIRequest = async (
+ client: ESSearchClient,
+ source: InfraSource,
+ snapshotRequest: SnapshotRequest
+): Promise => {
+ const timeRangeWithIntervalApplied = await createTimeRangeWithInterval(client, {
+ ...snapshotRequest,
+ filterQuery: parseFilterQuery(snapshotRequest.filterQuery),
+ sourceConfiguration: source.configuration,
+ });
+
+ const metricsApiRequest: MetricsAPIRequest = {
+ indexPattern: calculateIndexPatterBasedOnMetrics(snapshotRequest, source),
+ timerange: {
+ field: source.configuration.fields.timestamp,
+ from: timeRangeWithIntervalApplied.from,
+ to: timeRangeWithIntervalApplied.to,
+ interval: timeRangeWithIntervalApplied.interval,
+ },
+ metrics: transformSnapshotMetricsToMetricsAPIMetrics(snapshotRequest),
+ limit: snapshotRequest.overrideCompositeSize ? snapshotRequest.overrideCompositeSize : 10,
+ alignDataToEnd: true,
+ };
+
+ const filters = [];
+ const parsedFilters = parseFilterQuery(snapshotRequest.filterQuery);
+ if (parsedFilters) {
+ filters.push(parsedFilters);
+ }
+
+ if (snapshotRequest.accountId) {
+ filters.push({ term: { 'cloud.account.id': snapshotRequest.accountId } });
+ }
+
+ if (snapshotRequest.region) {
+ filters.push({ term: { 'cloud.region': snapshotRequest.region } });
+ }
+
+ const inventoryFields = findInventoryFields(
+ snapshotRequest.nodeType,
+ source.configuration.fields
+ );
+ const groupBy = snapshotRequest.groupBy.map((g) => g.field).filter(Boolean) as string[];
+ metricsApiRequest.groupBy = [...groupBy, inventoryFields.id];
+
+ const metaAggregation = {
+ id: META_KEY,
+ aggregations: {
+ [META_KEY]: {
+ top_hits: {
+ size: 1,
+ _source: [inventoryFields.name],
+ sort: [{ [source.configuration.fields.timestamp]: 'desc' }],
+ },
+ },
+ },
+ };
+ if (inventoryFields.ip) {
+ metaAggregation.aggregations[META_KEY].top_hits._source.push(inventoryFields.ip);
+ }
+ metricsApiRequest.metrics.push(metaAggregation);
+
+ if (filters.length) {
+ metricsApiRequest.filters = filters;
+ }
+
+ return metricsApiRequest;
+};
diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/transform_snapshot_metrics_to_metrics_api_metrics.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_snapshot_metrics_to_metrics_api_metrics.ts
new file mode 100644
index 0000000000000..6f7c88eda5d7a
--- /dev/null
+++ b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_snapshot_metrics_to_metrics_api_metrics.ts
@@ -0,0 +1,38 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic';
+import { findInventoryModel } from '../../../../common/inventory_models';
+import {
+ MetricsAPIMetric,
+ SnapshotRequest,
+ SnapshotCustomMetricInputRT,
+} from '../../../../common/http_api';
+
+export const transformSnapshotMetricsToMetricsAPIMetrics = (
+ snapshotRequest: SnapshotRequest
+): MetricsAPIMetric[] => {
+ return snapshotRequest.metrics.map((metric, index) => {
+ const inventoryModel = findInventoryModel(snapshotRequest.nodeType);
+ if (SnapshotCustomMetricInputRT.is(metric)) {
+ const customId = `custom_${index}`;
+ if (metric.aggregation === 'rate') {
+ return { id: customId, aggregations: networkTraffic(customId, metric.field) };
+ }
+ return {
+ id: customId,
+ aggregations: {
+ [customId]: {
+ [metric.aggregation]: {
+ field: metric.field,
+ },
+ },
+ },
+ };
+ }
+ return { id: metric.type, aggregations: inventoryModel.metrics.snapshot?.[metric.type] };
+ });
+};
diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/trasform_metrics_ui_response.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/trasform_metrics_ui_response.ts
new file mode 100644
index 0000000000000..309598d71c361
--- /dev/null
+++ b/x-pack/plugins/infra/server/routes/snapshot/lib/trasform_metrics_ui_response.ts
@@ -0,0 +1,87 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { get, max, sum, last, isNumber } from 'lodash';
+import { SnapshotMetricType } from '../../../../common/inventory_models/types';
+import {
+ MetricsAPIResponse,
+ SnapshotNodeResponse,
+ MetricsAPIRequest,
+ MetricsExplorerColumnType,
+ MetricsAPIRow,
+ SnapshotRequest,
+ SnapshotNodePath,
+ SnapshotNodeMetric,
+} from '../../../../common/http_api';
+import { META_KEY } from './constants';
+import { InfraSource } from '../../../lib/sources';
+import { applyMetadataToLastPath } from './apply_metadata_to_last_path';
+
+const getMetricValue = (row: MetricsAPIRow) => {
+ if (!isNumber(row.metric_0)) return null;
+ const value = row.metric_0;
+ return isFinite(value) ? value : null;
+};
+
+const calculateMax = (rows: MetricsAPIRow[]) => {
+ return max(rows.map(getMetricValue)) || 0;
+};
+
+const calculateAvg = (rows: MetricsAPIRow[]): number => {
+ return sum(rows.map(getMetricValue)) / rows.length || 0;
+};
+
+const getLastValue = (rows: MetricsAPIRow[]) => {
+ const row = last(rows);
+ if (!row) return null;
+ return getMetricValue(row);
+};
+
+export const transformMetricsApiResponseToSnapshotResponse = (
+ options: MetricsAPIRequest,
+ snapshotRequest: SnapshotRequest,
+ source: InfraSource,
+ metricsApiResponse: MetricsAPIResponse
+): SnapshotNodeResponse => {
+ const nodes = metricsApiResponse.series.map((series) => {
+ const node = {
+ metrics: options.metrics
+ .filter((m) => m.id !== META_KEY)
+ .map((metric) => {
+ const name = metric.id as SnapshotMetricType;
+ const timeseries = {
+ id: name,
+ columns: [
+ { name: 'timestamp', type: 'date' as MetricsExplorerColumnType },
+ { name: 'metric_0', type: 'number' as MetricsExplorerColumnType },
+ ],
+ rows: series.rows.map((row) => {
+ return { timestamp: row.timestamp, metric_0: get(row, metric.id, null) };
+ }),
+ };
+ const maxValue = calculateMax(timeseries.rows);
+ const avg = calculateAvg(timeseries.rows);
+ const value = getLastValue(timeseries.rows);
+ const nodeMetric: SnapshotNodeMetric = { name, max: maxValue, value, avg };
+ if (snapshotRequest.includeTimeseries) {
+ nodeMetric.timeseries = timeseries;
+ }
+ return nodeMetric;
+ }),
+ path:
+ series.keys?.map((key) => {
+ return { value: key, label: key } as SnapshotNodePath;
+ }) ?? [],
+ name: '',
+ };
+
+ const path = applyMetadataToLastPath(series, node, snapshotRequest, source);
+ const lastPath = last(path);
+ const name = (lastPath && lastPath.label) || 'N/A';
+ return { ...node, path, name };
+ });
+ return { nodes, interval: `${metricsApiResponse.info.interval}s` };
+};
diff --git a/x-pack/plugins/infra/server/utils/calculate_metric_interval.ts b/x-pack/plugins/infra/server/utils/calculate_metric_interval.ts
index a3d674b324ae8..6d16e045d26d5 100644
--- a/x-pack/plugins/infra/server/utils/calculate_metric_interval.ts
+++ b/x-pack/plugins/infra/server/utils/calculate_metric_interval.ts
@@ -8,7 +8,7 @@
import { findInventoryModel } from '../../common/inventory_models';
// import { KibanaFramework } from '../lib/adapters/framework/kibana_framework_adapter';
import { InventoryItemType } from '../../common/inventory_models/types';
-import { ESSearchClient } from '../lib/snapshot';
+import { ESSearchClient } from '../lib/metrics/types';
interface Options {
indexPattern: string;
diff --git a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json
index d75a914e080d7..b7856e6d57402 100644
--- a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json
+++ b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json
@@ -1425,11 +1425,13 @@
},
"icons": [
{
- "src": "/package/coredns-1.0.1/img/icon.png",
+ "path": "/package/coredns-1.0.1/img/icon.png",
+ "src": "/img/icon.png",
"size": "1800x1800"
},
{
- "src": "/package/coredns-1.0.1/img/icon.svg",
+ "path": "/package/coredns-1.0.1/img/icon.svg",
+ "src": "/img/icon.svg",
"size": "255x144",
"type": "image/svg+xml"
}
@@ -1704,7 +1706,8 @@
},
"icons": [
{
- "src": "/package/endpoint/0.3.0/img/logo-endpoint-64-color.svg",
+ "path": "/package/endpoint/0.3.0/img/logo-endpoint-64-color.svg",
+ "src": "/img/logo-endpoint-64-color.svg",
"size": "16x16",
"type": "image/svg+xml"
}
@@ -2001,7 +2004,8 @@
"download": "/epr/aws/aws-0.0.3.tar.gz",
"icons": [
{
- "src": "/package/aws/0.0.3/img/logo_aws.svg",
+ "path": "/package/aws/0.0.3/img/logo_aws.svg",
+ "src": "/img/logo_aws.svg",
"title": "logo aws",
"size": "32x32",
"type": "image/svg+xml"
@@ -2019,7 +2023,8 @@
"download": "/epr/endpoint/endpoint-0.1.0.tar.gz",
"icons": [
{
- "src": "/package/endpoint/0.1.0/img/logo-endpoint-64-color.svg",
+ "path": "/package/endpoint/0.1.0/img/logo-endpoint-64-color.svg",
+ "src": "/img/logo-endpoint-64-color.svg",
"size": "16x16",
"type": "image/svg+xml"
}
@@ -2087,7 +2092,8 @@
"download": "/epr/log/log-0.9.0.tar.gz",
"icons": [
{
- "src": "/package/log/0.9.0/img/icon.svg",
+ "path": "/package/log/0.9.0/img/icon.svg",
+ "src": "/img/icon.svg",
"type": "image/svg+xml"
}
],
@@ -2103,7 +2109,8 @@
"download": "/epr/longdocs/longdocs-1.0.4.tar.gz",
"icons": [
{
- "src": "/package/longdocs/1.0.4/img/icon.svg",
+ "path": "/package/longdocs/1.0.4/img/icon.svg",
+ "src": "/img/icon.svg",
"type": "image/svg+xml"
}
],
@@ -2119,7 +2126,8 @@
"download": "/epr/metricsonly/metricsonly-2.0.1.tar.gz",
"icons": [
{
- "src": "/package/metricsonly/2.0.1/img/icon.svg",
+ "path": "/package/metricsonly/2.0.1/img/icon.svg",
+ "src": "/img/icon.svg",
"type": "image/svg+xml"
}
],
@@ -2135,7 +2143,8 @@
"download": "/epr/multiversion/multiversion-1.1.0.tar.gz",
"icons": [
{
- "src": "/package/multiversion/1.1.0/img/icon.svg",
+ "path": "/package/multiversion/1.1.0/img/icon.svg",
+ "src": "/img/icon.svg",
"type": "image/svg+xml"
}
],
@@ -2151,7 +2160,8 @@
"download": "/epr/mysql/mysql-0.1.0.tar.gz",
"icons": [
{
- "src": "/package/mysql/0.1.0/img/logo_mysql.svg",
+ "path": "/package/mysql/0.1.0/img/logo_mysql.svg",
+ "src": "/img/logo_mysql.svg",
"title": "logo mysql",
"size": "32x32",
"type": "image/svg+xml"
@@ -2169,7 +2179,8 @@
"download": "/epr/nginx/nginx-0.1.0.tar.gz",
"icons": [
{
- "src": "/package/nginx/0.1.0/img/logo_nginx.svg",
+ "path": "/package/nginx/0.1.0/img/logo_nginx.svg",
+ "src": "/img/logo_nginx.svg",
"title": "logo nginx",
"size": "32x32",
"type": "image/svg+xml"
@@ -2187,7 +2198,8 @@
"download": "/epr/redis/redis-0.1.0.tar.gz",
"icons": [
{
- "src": "/package/redis/0.1.0/img/logo_redis.svg",
+ "path": "/package/redis/0.1.0/img/logo_redis.svg",
+ "src": "/img/logo_redis.svg",
"title": "logo redis",
"size": "32x32",
"type": "image/svg+xml"
@@ -2205,7 +2217,8 @@
"download": "/epr/reference/reference-1.0.0.tar.gz",
"icons": [
{
- "src": "/package/reference/1.0.0/img/icon.svg",
+ "path": "/package/reference/1.0.0/img/icon.svg",
+ "src": "/img/icon.svg",
"size": "32x32",
"type": "image/svg+xml"
}
@@ -2222,7 +2235,8 @@
"download": "/epr/system/system-0.1.0.tar.gz",
"icons": [
{
- "src": "/package/system/0.1.0/img/system.svg",
+ "path": "/package/system/0.1.0/img/system.svg",
+ "src": "/img/system.svg",
"title": "system",
"size": "1000x1000",
"type": "image/svg+xml"
@@ -3913,11 +3927,20 @@
"src": {
"type": "string"
},
+ "path": {
+ "type": "string"
+ },
"title": {
"type": "string"
+ },
+ "size": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string"
}
},
- "required": ["src"]
+ "required": ["src", "path"]
}
},
"icons": {
diff --git a/x-pack/plugins/ingest_manager/common/types/models/epm.ts b/x-pack/plugins/ingest_manager/common/types/models/epm.ts
index f083400997870..8bc5d9f7210b2 100644
--- a/x-pack/plugins/ingest_manager/common/types/models/epm.ts
+++ b/x-pack/plugins/ingest_manager/common/types/models/epm.ts
@@ -74,10 +74,8 @@ export interface RegistryPackage {
}
interface RegistryImage {
- // https://github.com/elastic/package-registry/blob/master/util/package.go#L74
- // says src is potentially missing but I couldn't find any examples
- // it seems like src should be required. How can you have an image with no reference to the content?
src: string;
+ path: string;
title?: string;
size?: string;
type?: string;
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_package_icon_type.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_package_icon_type.ts
index e5a7191372e9c..690ffdf46f704 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_package_icon_type.ts
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_package_icon_type.ts
@@ -42,7 +42,7 @@ export const usePackageIconType = ({
const svgIcons = (paramIcons || iconList)?.filter(
(iconDef) => iconDef.type === 'image/svg+xml'
);
- const localIconSrc = Array.isArray(svgIcons) && svgIcons[0]?.src;
+ const localIconSrc = Array.isArray(svgIcons) && (svgIcons[0].path || svgIcons[0].src);
if (localIconSrc) {
CACHED_ICONS.set(pkgKey, toImage(localIconSrc));
setIconType(CACHED_ICONS.get(pkgKey) || '');
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/screenshots.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/screenshots.tsx
index d8388a71556d6..6326e9072be8e 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/screenshots.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/screenshots.tsx
@@ -75,7 +75,7 @@ export function Screenshots(props: ScreenshotProps) {
set image to same width. Will need to update if size changes.
*/}
;
+type ITestEsErrorsFnParams = [errorCode: string, error: any, expectedMessage: string];
describe('defaultIngestErrorHandler', () => {
let mockContract: ReturnType;
@@ -29,6 +32,55 @@ describe('defaultIngestErrorHandler', () => {
appContextService.stop();
});
+ async function testEsErrorsFn(...args: ITestEsErrorsFnParams) {
+ const [, error, expectedMessage] = args;
+ jest.clearAllMocks();
+ const response = httpServerMock.createResponseFactory();
+ await defaultIngestErrorHandler({ error, response });
+
+ // response
+ expect(response.ok).toHaveBeenCalledTimes(0);
+ expect(response.customError).toHaveBeenCalledTimes(1);
+ expect(response.customError).toHaveBeenCalledWith({
+ statusCode: error.status,
+ body: { message: expectedMessage },
+ });
+
+ // logging
+ expect(mockContract.logger?.error).toHaveBeenCalledTimes(1);
+ expect(mockContract.logger?.error).toHaveBeenCalledWith(expectedMessage);
+ }
+
+ describe('use the HTTP error status code provided by LegacyESErrors', () => {
+ const statusCodes = Object.keys(LegacyESErrors).filter((key) => /^\d+$/.test(key));
+ const errorCodes = statusCodes.filter((key) => parseInt(key, 10) >= 400);
+ const casesWithPathResponse: ITestEsErrorsFnParams[] = errorCodes.map((errorCode) => [
+ errorCode,
+ new LegacyESErrors[errorCode]('the root message', {
+ path: '/path/to/call',
+ response: 'response is here',
+ }),
+ 'the root message response from /path/to/call: response is here',
+ ]);
+ const casesWithOtherMeta: ITestEsErrorsFnParams[] = errorCodes.map((errorCode) => [
+ errorCode,
+ new LegacyESErrors[errorCode]('the root message', {
+ other: '/path/to/call',
+ props: 'response is here',
+ }),
+ 'the root message',
+ ]);
+ const casesWithoutMeta: ITestEsErrorsFnParams[] = errorCodes.map((errorCode) => [
+ errorCode,
+ new LegacyESErrors[errorCode]('some message'),
+ 'some message',
+ ]);
+
+ test.each(casesWithPathResponse)('%d - with path & response', testEsErrorsFn);
+ test.each(casesWithOtherMeta)('%d - with other metadata', testEsErrorsFn);
+ test.each(casesWithoutMeta)('%d - without metadata', testEsErrorsFn);
+ });
+
describe('IngestManagerError', () => {
it('502: RegistryError', async () => {
const error = new RegistryError('xyz');
diff --git a/x-pack/plugins/ingest_manager/server/errors.ts b/x-pack/plugins/ingest_manager/server/errors/handlers.ts
similarity index 60%
rename from x-pack/plugins/ingest_manager/server/errors.ts
rename to x-pack/plugins/ingest_manager/server/errors/handlers.ts
index 9829a4de23d7b..9f776565cf262 100644
--- a/x-pack/plugins/ingest_manager/server/errors.ts
+++ b/x-pack/plugins/ingest_manager/server/errors/handlers.ts
@@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
-/* eslint-disable max-classes-per-file */
import Boom, { isBoom } from 'boom';
import {
RequestHandlerContext,
@@ -12,25 +11,39 @@ import {
IKibanaResponse,
KibanaResponseFactory,
} from 'src/core/server';
-import { appContextService } from './services';
+import { errors as LegacyESErrors } from 'elasticsearch';
+import { appContextService } from '../services';
+import { IngestManagerError, RegistryError, PackageNotFoundError } from './index';
type IngestErrorHandler = (
params: IngestErrorHandlerParams
) => IKibanaResponse | Promise;
-
interface IngestErrorHandlerParams {
error: IngestManagerError | Boom | Error;
response: KibanaResponseFactory;
request?: KibanaRequest;
context?: RequestHandlerContext;
}
+// unsure if this is correct. would prefer to use something "official"
+// this type is based on BadRequest values observed while debugging https://github.com/elastic/kibana/issues/75862
-export class IngestManagerError extends Error {
- constructor(message?: string) {
- super(message);
- this.name = this.constructor.name; // for stack traces
- }
+interface LegacyESClientError {
+ message: string;
+ stack: string;
+ status: number;
+ displayName: string;
+ path?: string;
+ query?: string | undefined;
+ body?: {
+ error: object;
+ status: number;
+ };
+ statusCode?: number;
+ response?: string;
}
+export const isLegacyESClientError = (error: any): error is LegacyESClientError => {
+ return error instanceof LegacyESErrors._Abstract;
+};
const getHTTPResponseCode = (error: IngestManagerError): number => {
if (error instanceof RegistryError) {
@@ -48,6 +61,22 @@ export const defaultIngestErrorHandler: IngestErrorHandler = async ({
response,
}: IngestErrorHandlerParams): Promise => {
const logger = appContextService.getLogger();
+ if (isLegacyESClientError(error)) {
+ // there was a problem communicating with ES (e.g. via `callCluster`)
+ // only log the message
+ const message =
+ error?.path && error?.response
+ ? // if possible, return the failing endpoint and its response
+ `${error.message} response from ${error.path}: ${error.response}`
+ : error.message;
+
+ logger.error(message);
+
+ return response.customError({
+ statusCode: error?.statusCode || error.status,
+ body: { message },
+ });
+ }
// our "expected" errors
if (error instanceof IngestManagerError) {
@@ -76,9 +105,3 @@ export const defaultIngestErrorHandler: IngestErrorHandler = async ({
body: { message: error.message },
});
};
-
-export class RegistryError extends IngestManagerError {}
-export class RegistryConnectionError extends RegistryError {}
-export class RegistryResponseError extends RegistryError {}
-export class PackageNotFoundError extends IngestManagerError {}
-export class PackageOutdatedError extends IngestManagerError {}
diff --git a/x-pack/plugins/ingest_manager/server/errors/index.ts b/x-pack/plugins/ingest_manager/server/errors/index.ts
new file mode 100644
index 0000000000000..5e36a2ec9a884
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/errors/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.
+ */
+
+/* eslint-disable max-classes-per-file */
+export { defaultIngestErrorHandler } from './handlers';
+
+export class IngestManagerError extends Error {
+ constructor(message?: string) {
+ super(message);
+ this.name = this.constructor.name; // for stack traces
+ }
+}
+export class RegistryError extends IngestManagerError {}
+export class RegistryConnectionError extends RegistryError {}
+export class RegistryResponseError extends RegistryError {}
+export class PackageNotFoundError extends IngestManagerError {}
+export class PackageOutdatedError extends IngestManagerError {}
diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/install.ts
index 44e4eddfbbe6a..878c6ea8f2804 100644
--- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/install.ts
+++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/install.ts
@@ -156,7 +156,12 @@ async function installPipeline({
body: pipeline.contentForInstallation,
};
if (pipeline.extension === 'yml') {
- callClusterParams.headers = { ['Content-Type']: 'application/yaml' };
+ callClusterParams.headers = {
+ // pipeline is YAML
+ 'Content-Type': 'application/yaml',
+ // but we want JSON responses (to extract error messages, status code, or other metadata)
+ Accept: 'application/json',
+ };
}
// This uses the catch-all endpoint 'transport.request' because we have to explicitly
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/kv.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/kv.tsx
index f51bf19ad180a..4104e8f727ab1 100644
--- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/kv.tsx
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/kv.tsx
@@ -33,9 +33,15 @@ const fieldsConfig: FieldsConfig = {
label: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.fieldSplitFieldLabel', {
defaultMessage: 'Field split',
}),
- helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.fieldSplitHelpText', {
- defaultMessage: 'Regex pattern for splitting key-value pairs.',
- }),
+ helpText: (
+ {'" "'},
+ }}
+ />
+ ),
validations: [
{
validator: emptyField(
@@ -52,9 +58,15 @@ const fieldsConfig: FieldsConfig = {
label: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.valueSplitFieldLabel', {
defaultMessage: 'Value split',
}),
- helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.valueSplitHelpText', {
- defaultMessage: 'Regex pattern for splitting the key from the value within a key-value pair.',
- }),
+ helpText: (
+ {'"="'},
+ }}
+ />
+ ),
validations: [
{
validator: emptyField(
@@ -75,8 +87,7 @@ const fieldsConfig: FieldsConfig = {
defaultMessage: 'Include keys',
}),
helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.includeKeysHelpText', {
- defaultMessage:
- 'List of keys to filter and insert into document. Defaults to including all keys.',
+ defaultMessage: 'List of extracted keys to include in the output. Defaults to all keys.',
}),
},
@@ -88,7 +99,7 @@ const fieldsConfig: FieldsConfig = {
defaultMessage: 'Exclude keys',
}),
helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.excludeKeysHelpText', {
- defaultMessage: 'List of keys to exclude from document.',
+ defaultMessage: 'List of extracted keys to exclude from the output.',
}),
},
@@ -99,7 +110,7 @@ const fieldsConfig: FieldsConfig = {
defaultMessage: 'Prefix',
}),
helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.prefixHelpText', {
- defaultMessage: 'Prefix to be added to extracted keys.',
+ defaultMessage: 'Prefix to add to extracted keys.',
}),
},
@@ -136,7 +147,7 @@ const fieldsConfig: FieldsConfig = {
helpText: (
{'()'},
angle: <>,
@@ -154,7 +165,7 @@ export const Kv: FunctionComponent = () => {
<>
@@ -166,8 +177,7 @@ export const Kv: FunctionComponent = () => {
helpText={i18n.translate(
'xpack.ingestPipelines.pipelineEditor.kvForm.targetFieldHelpText',
{
- defaultMessage:
- 'Field to insert the extracted keys into. Defaults to the root of the document.',
+ defaultMessage: 'Output field for the extracted fields. Defaults to the document root.',
}
)}
/>
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/lowercase.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/lowercase.tsx
index 9db313a05007f..0d8170338ea10 100644
--- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/lowercase.tsx
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/lowercase.tsx
@@ -6,8 +6,6 @@
import React, { FunctionComponent } from 'react';
import { i18n } from '@kbn/i18n';
-import { FormattedMessage } from '@kbn/i18n/react';
-import { EuiCode } from '@elastic/eui';
import { FieldNameField } from './common_fields/field_name_field';
import { TargetField } from './common_fields/target_field';
@@ -23,17 +21,7 @@ export const Lowercase: FunctionComponent = () => {
)}
/>
- {'field'},
- }}
- />
- }
- />
+
>
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/pipeline.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/pipeline.tsx
index c785cf935833d..57843e2411359 100644
--- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/pipeline.tsx
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/pipeline.tsx
@@ -27,7 +27,7 @@ const fieldsConfig: FieldsConfig = {
helpText: i18n.translate(
'xpack.ingestPipelines.pipelineEditor.pipelineForm.pipelineNameFieldHelpText',
{
- defaultMessage: 'Name of the pipeline to execute.',
+ defaultMessage: 'Name of the ingest pipeline to run.',
}
),
validations: [
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/remove.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/remove.tsx
index 3e90ce2b76f7b..3ba1cdb0c802d 100644
--- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/remove.tsx
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/remove.tsx
@@ -29,7 +29,7 @@ const fieldsConfig: FieldsConfig = {
defaultMessage: 'Fields',
}),
helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.removeForm.fieldNameHelpText', {
- defaultMessage: 'Fields to be removed.',
+ defaultMessage: 'Fields to remove.',
}),
validations: [
{
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/rename.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/rename.tsx
index 8b796d9664586..099e2bd2c80fb 100644
--- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/rename.tsx
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/rename.tsx
@@ -21,7 +21,7 @@ export const Rename: FunctionComponent = () => {
@@ -31,7 +31,7 @@ export const Rename: FunctionComponent = () => {
})}
helpText={i18n.translate(
'xpack.ingestPipelines.pipelineEditor.renameForm.targetFieldHelpText',
- { defaultMessage: 'Name of the new field.' }
+ { defaultMessage: 'New field name. This field cannot already exist.' }
)}
validations={[
{
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/script.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/script.tsx
index ae0bbbb490ae9..de28f66766603 100644
--- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/script.tsx
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/script.tsx
@@ -32,7 +32,7 @@ const fieldsConfig: FieldsConfig = {
helpText: i18n.translate(
'xpack.ingestPipelines.pipelineEditor.scriptForm.storedScriptIDFieldHelpText',
{
- defaultMessage: 'Stored script reference.',
+ defaultMessage: 'ID of the stored script to run.',
}
),
validations: [
@@ -55,7 +55,7 @@ const fieldsConfig: FieldsConfig = {
helpText: i18n.translate(
'xpack.ingestPipelines.pipelineEditor.scriptForm.sourceFieldHelpText',
{
- defaultMessage: 'Script to be executed.',
+ defaultMessage: 'Inline script to run.',
}
),
validations: [
@@ -98,7 +98,7 @@ const fieldsConfig: FieldsConfig = {
helpText: i18n.translate(
'xpack.ingestPipelines.pipelineEditor.scriptForm.paramsFieldHelpText',
{
- defaultMessage: 'Script parameters.',
+ defaultMessage: 'Named parameters passed to the script as variables.',
}
),
validations: [
@@ -128,7 +128,7 @@ export const Script: FormFieldsComponent = ({ initialFieldValues }) => {
setShowId((v) => !v)}
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set.tsx
index c282be35e5071..04ea0c44c3513 100644
--- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set.tsx
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set.tsx
@@ -32,13 +32,13 @@ const fieldsConfig: FieldsConfig = {
defaultMessage: 'Value',
}),
helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.valueFieldHelpText', {
- defaultMessage: 'Value to be set for the field',
+ defaultMessage: 'Value for the field.',
}),
validations: [
{
validator: emptyField(
i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.valueRequiredError', {
- defaultMessage: 'A value is required',
+ defaultMessage: 'A value is required.',
})
),
},
@@ -53,9 +53,15 @@ const fieldsConfig: FieldsConfig = {
label: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.overrideFieldLabel', {
defaultMessage: 'Override',
}),
- helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.overrideFieldHelpText', {
- defaultMessage: 'If disabled, fields containing non-null values will not be updated.',
- }),
+ helpText: (
+ {'null'},
+ }}
+ />
+ ),
},
ignore_empty_value: {
type: FIELD_TYPES.TOGGLE,
@@ -71,7 +77,8 @@ const fieldsConfig: FieldsConfig = {
helpText: (
{'value'},
nullValue: {'null'},
@@ -89,7 +96,7 @@ export const SetProcessor: FunctionComponent = () => {
<>
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set_security_user.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set_security_user.tsx
index 78128b3d54c75..46bfe8c97ebea 100644
--- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set_security_user.tsx
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set_security_user.tsx
@@ -44,7 +44,7 @@ const fieldsConfig: FieldsConfig = {
helpText: (
[{helpTextValues}],
}}
@@ -60,7 +60,7 @@ export const SetSecurityUser: FunctionComponent = () => {
helpText={i18n.translate(
'xpack.ingestPipelines.pipelineEditor.setSecurityUserForm.fieldNameField',
{
- defaultMessage: 'Field to store the user information',
+ defaultMessage: 'Output field.',
}
)}
/>
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/sort.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/sort.tsx
index cdd0ff888accf..c8c0562011fd6 100644
--- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/sort.tsx
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/sort.tsx
@@ -24,7 +24,8 @@ const fieldsConfig: FieldsConfig = {
defaultMessage: 'Order',
}),
helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.sortForm.orderFieldHelpText', {
- defaultMessage: 'Sort order to use',
+ defaultMessage:
+ 'Sort order. Arrays containing a mix of strings and numbers are sorted lexicographically.',
}),
},
};
@@ -35,7 +36,7 @@ export const Sort: FunctionComponent = () => {
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/split.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/split.tsx
index b48ce74110b39..fa178aaddd314 100644
--- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/split.tsx
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/split.tsx
@@ -33,7 +33,7 @@ const fieldsConfig: FieldsConfig = {
helpText: i18n.translate(
'xpack.ingestPipelines.pipelineEditor.splitForm.separatorFieldHelpText',
{
- defaultMessage: 'Regex to match a separator',
+ defaultMessage: 'Regex pattern used to delimit the field value.',
}
),
validations: [
@@ -60,7 +60,7 @@ const fieldsConfig: FieldsConfig = {
),
helpText: i18n.translate(
'xpack.ingestPipelines.pipelineEditor.splitForm.preserveTrailingFieldHelpText',
- { defaultMessage: 'If enabled, preserve any trailing space.' }
+ { defaultMessage: 'Preserve any trailing whitespace in the split field values.' }
),
},
};
@@ -71,7 +71,7 @@ export const Split: FunctionComponent = () => {
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx
index 59ec64944a3c9..9de371f8d0024 100644
--- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx
@@ -107,7 +107,7 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = {
defaultMessage: 'CSV',
}),
description: i18n.translate('xpack.ingestPipelines.processors.description.csv', {
- defaultMessage: 'Extracts fields values from CSV data.',
+ defaultMessage: 'Extracts field values from CSV data.',
}),
},
date: {
@@ -306,7 +306,10 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = {
FieldsComponent: Kv,
docLinkPath: '/kv-processor.html',
label: i18n.translate('xpack.ingestPipelines.processors.label.kv', {
- defaultMessage: 'KV',
+ defaultMessage: 'Key-value (KV)',
+ }),
+ description: i18n.translate('xpack.ingestPipelines.processors.description.kv', {
+ defaultMessage: 'Extracts fields from a string containing key-value pairs.',
}),
},
lowercase: {
@@ -315,6 +318,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = {
label: i18n.translate('xpack.ingestPipelines.processors.label.lowercase', {
defaultMessage: 'Lowercase',
}),
+ description: i18n.translate('xpack.ingestPipelines.processors.description.lowercase', {
+ defaultMessage: 'Converts a string to lowercase.',
+ }),
},
pipeline: {
FieldsComponent: Pipeline,
@@ -322,6 +328,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = {
label: i18n.translate('xpack.ingestPipelines.processors.label.pipeline', {
defaultMessage: 'Pipeline',
}),
+ description: i18n.translate('xpack.ingestPipelines.processors.description.pipeline', {
+ defaultMessage: 'Runs another ingest node pipeline.',
+ }),
},
remove: {
FieldsComponent: Remove,
@@ -329,6 +338,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = {
label: i18n.translate('xpack.ingestPipelines.processors.label.remove', {
defaultMessage: 'Remove',
}),
+ description: i18n.translate('xpack.ingestPipelines.processors.description.remove', {
+ defaultMessage: 'Removes one or more fields.',
+ }),
},
rename: {
FieldsComponent: Rename,
@@ -336,6 +348,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = {
label: i18n.translate('xpack.ingestPipelines.processors.label.rename', {
defaultMessage: 'Rename',
}),
+ description: i18n.translate('xpack.ingestPipelines.processors.description.rename', {
+ defaultMessage: 'Renames an existing field.',
+ }),
},
script: {
FieldsComponent: Script,
@@ -343,6 +358,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = {
label: i18n.translate('xpack.ingestPipelines.processors.label.script', {
defaultMessage: 'Script',
}),
+ description: i18n.translate('xpack.ingestPipelines.processors.description.script', {
+ defaultMessage: 'Runs a script on incoming documents.',
+ }),
},
set: {
FieldsComponent: SetProcessor,
@@ -350,6 +368,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = {
label: i18n.translate('xpack.ingestPipelines.processors.label.set', {
defaultMessage: 'Set',
}),
+ description: i18n.translate('xpack.ingestPipelines.processors.description.set', {
+ defaultMessage: 'Sets the value of a field.',
+ }),
},
set_security_user: {
FieldsComponent: SetSecurityUser,
@@ -357,12 +378,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = {
label: i18n.translate('xpack.ingestPipelines.processors.label.setSecurityUser', {
defaultMessage: 'Set security user',
}),
- },
- split: {
- FieldsComponent: Split,
- docLinkPath: '/split-processor.html',
- label: i18n.translate('xpack.ingestPipelines.processors.label.split', {
- defaultMessage: 'Split',
+ description: i18n.translate('xpack.ingestPipelines.processors.description.setSecurityUser', {
+ defaultMessage:
+ 'Adds details about the current user, such user name and email address, to incoming documents. Requires an authenticated user for the indexing request.',
}),
},
sort: {
@@ -371,6 +389,19 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = {
label: i18n.translate('xpack.ingestPipelines.processors.label.sort', {
defaultMessage: 'Sort',
}),
+ description: i18n.translate('xpack.ingestPipelines.processors.description.sort', {
+ defaultMessage: "Sorts a field's array elements.",
+ }),
+ },
+ split: {
+ FieldsComponent: Split,
+ docLinkPath: '/split-processor.html',
+ label: i18n.translate('xpack.ingestPipelines.processors.label.split', {
+ defaultMessage: 'Split',
+ }),
+ description: i18n.translate('xpack.ingestPipelines.processors.description.split', {
+ defaultMessage: 'Splits a field value into an array.',
+ }),
},
trim: {
FieldsComponent: undefined, // TODO: Implement
diff --git a/x-pack/plugins/maps/public/classes/joins/inner_join.js b/x-pack/plugins/maps/public/classes/joins/inner_join.js
index 76afe2430b818..75bf59d9d6404 100644
--- a/x-pack/plugins/maps/public/classes/joins/inner_join.js
+++ b/x-pack/plugins/maps/public/classes/joins/inner_join.js
@@ -94,8 +94,8 @@ export class InnerJoin {
return this._descriptor;
}
- async filterAndFormatPropertiesForTooltip(properties) {
- return await this._rightSource.filterAndFormatPropertiesToHtml(properties);
+ async getTooltipProperties(properties) {
+ return await this._rightSource.getTooltipProperties(properties);
}
getIndexPatternIds() {
diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js
index c49d0044e6ad6..27c344b713a60 100644
--- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js
+++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js
@@ -949,13 +949,11 @@ export class VectorLayer extends AbstractLayer {
async getPropertiesForTooltip(properties) {
const vectorSource = this.getSource();
- let allProperties = await vectorSource.filterAndFormatPropertiesToHtml(properties);
+ let allProperties = await vectorSource.getTooltipProperties(properties);
this._addJoinsToSourceTooltips(allProperties);
for (let i = 0; i < this.getJoins().length; i++) {
- const propsFromJoin = await this.getJoins()[i].filterAndFormatPropertiesForTooltip(
- properties
- );
+ const propsFromJoin = await this.getJoins()[i].getTooltipProperties(properties);
allProperties = [...allProperties, ...propsFromJoin];
}
return allProperties;
diff --git a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.test.tsx b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.test.tsx
index c5d6ced76b5c0..674ee832daab9 100644
--- a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.test.tsx
+++ b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.test.tsx
@@ -17,10 +17,10 @@ function makeEMSFileSource(tooltipProperties: string[]) {
}
describe('EMS file source', () => {
- describe('filterAndFormatPropertiesToHtml', () => {
+ describe('getTooltipProperties', () => {
it('should create tooltip-properties with human readable label', async () => {
const mockEMSFileSource = makeEMSFileSource(['iso2']);
- const out = await mockEMSFileSource.filterAndFormatPropertiesToHtml({
+ const out = await mockEMSFileSource.getTooltipProperties({
iso2: 'US',
});
@@ -33,7 +33,7 @@ describe('EMS file source', () => {
it('should order tooltip-properties', async () => {
const tooltipProperties = ['iso3', 'iso2', 'name'];
const mockEMSFileSource = makeEMSFileSource(tooltipProperties);
- const out = await mockEMSFileSource.filterAndFormatPropertiesToHtml({
+ const out = await mockEMSFileSource.getTooltipProperties({
name: 'United States',
iso3: 'USA',
iso2: 'US',
diff --git a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx
index f55a7434d1217..5f73a9e23431b 100644
--- a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx
+++ b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx
@@ -23,7 +23,6 @@ import { ITooltipProperty } from '../../tooltips/tooltip_property';
export interface IEmsFileSource extends IVectorSource {
getEmsFieldLabel(emsFieldName: string): Promise;
- createField({ fieldName }: { fieldName: string }): IField;
}
export const sourceTitle = i18n.translate('xpack.maps.source.emsFileTitle', {
@@ -168,7 +167,7 @@ export class EMSFileSource extends AbstractVectorSource implements IEmsFileSourc
return this._tooltipFields.length > 0;
}
- async filterAndFormatPropertiesToHtml(properties: unknown): Promise {
+ async getTooltipProperties(properties: unknown): Promise {
const promises = this._tooltipFields.map((field) => {
// @ts-ignore
const value = properties[field.getName()];
diff --git a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.d.ts b/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.d.ts
deleted file mode 100644
index eb50cd7528c8b..0000000000000
--- a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.d.ts
+++ /dev/null
@@ -1,29 +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 { IESSource } from '../es_source';
-import { AbstractESSource } from '../es_source';
-import { AGG_TYPE } from '../../../../common/constants';
-import { IESAggField } from '../../fields/es_agg_field';
-import { AbstractESAggSourceDescriptor } from '../../../../common/descriptor_types';
-
-export interface IESAggSource extends IESSource {
- getAggKey(aggType: AGG_TYPE, fieldName: string): string;
- getAggLabel(aggType: AGG_TYPE, fieldName: string): string;
- getMetricFields(): IESAggField[];
- hasMatchingMetricField(fieldName: string): boolean;
- getMetricFieldForName(fieldName: string): IESAggField | null;
-}
-
-export class AbstractESAggSource extends AbstractESSource implements IESAggSource {
- constructor(sourceDescriptor: AbstractESAggSourceDescriptor, inspectorAdapters: object);
-
- getAggKey(aggType: AGG_TYPE, fieldName: string): string;
- getAggLabel(aggType: AGG_TYPE, fieldName: string): string;
- getMetricFields(): IESAggField[];
- hasMatchingMetricField(fieldName: string): boolean;
- getMetricFieldForName(fieldName: string): IESAggField | null;
-}
diff --git a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.js b/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts
similarity index 56%
rename from x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.js
rename to x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts
index e20c509ccd4a2..a9c886617d3af 100644
--- a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.js
+++ b/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts
@@ -5,19 +5,38 @@
*/
import { i18n } from '@kbn/i18n';
+import { Adapters } from 'src/plugins/inspector/public';
+import { GeoJsonProperties } from 'geojson';
+import { IESSource } from '../es_source';
import { AbstractESSource } from '../es_source';
import { esAggFieldsFactory } from '../../fields/es_agg_field';
import { AGG_TYPE, COUNT_PROP_LABEL, FIELD_ORIGIN } from '../../../../common/constants';
+import { IESAggField } from '../../fields/es_agg_field';
import { getSourceAggKey } from '../../../../common/get_agg_key';
+import { AbstractESAggSourceDescriptor, AggDescriptor } from '../../../../common/descriptor_types';
+import { IndexPattern } from '../../../../../../../src/plugins/data/public';
+import { IField } from '../../fields/field';
+import { ITooltipProperty } from '../../tooltips/tooltip_property';
export const DEFAULT_METRIC = { type: AGG_TYPE.COUNT };
+export interface IESAggSource extends IESSource {
+ getAggKey(aggType: AGG_TYPE, fieldName: string): string;
+ getAggLabel(aggType: AGG_TYPE, fieldName: string): string;
+ getMetricFields(): IESAggField[];
+ hasMatchingMetricField(fieldName: string): boolean;
+ getMetricFieldForName(fieldName: string): IESAggField | null;
+ getValueAggsDsl(indexPattern: IndexPattern): { [key: string]: unknown };
+}
+
export class AbstractESAggSource extends AbstractESSource {
- constructor(descriptor, inspectorAdapters) {
+ private readonly _metricFields: IESAggField[];
+
+ constructor(descriptor: AbstractESAggSourceDescriptor, inspectorAdapters: Adapters) {
super(descriptor, inspectorAdapters);
this._metricFields = [];
- if (this._descriptor.metrics) {
- this._descriptor.metrics.forEach((aggDescriptor) => {
+ if (descriptor.metrics) {
+ descriptor.metrics.forEach((aggDescriptor: AggDescriptor) => {
this._metricFields.push(
...esAggFieldsFactory(aggDescriptor, this, this.getOriginForField())
);
@@ -25,30 +44,31 @@ export class AbstractESAggSource extends AbstractESSource {
}
}
- getFieldByName(name) {
- return this.getMetricFieldForName(name);
+ getFieldByName(fieldName: string) {
+ return this.getMetricFieldForName(fieldName);
}
- createField() {
+ createField({ fieldName }: { fieldName: string }): IField {
throw new Error('Cannot create a new field from just a fieldname for an es_agg_source.');
}
- hasMatchingMetricField(fieldName) {
+ hasMatchingMetricField(fieldName: string): boolean {
const matchingField = this.getMetricFieldForName(fieldName);
return !!matchingField;
}
- getMetricFieldForName(fieldName) {
- return this.getMetricFields().find((metricField) => {
+ getMetricFieldForName(fieldName: string): IESAggField | null {
+ const targetMetricField = this.getMetricFields().find((metricField: IESAggField) => {
return metricField.getName() === fieldName;
});
+ return targetMetricField ? targetMetricField : null;
}
getOriginForField() {
return FIELD_ORIGIN.SOURCE;
}
- getMetricFields() {
+ getMetricFields(): IESAggField[] {
const metrics = this._metricFields.filter((esAggField) => esAggField.isValid());
// Handle case where metrics is empty because older saved object state is empty array or there are no valid aggs.
return metrics.length === 0
@@ -56,14 +76,14 @@ export class AbstractESAggSource extends AbstractESSource {
: metrics;
}
- getAggKey(aggType, fieldName) {
+ getAggKey(aggType: AGG_TYPE, fieldName: string): string {
return getSourceAggKey({
aggType,
aggFieldName: fieldName,
});
}
- getAggLabel(aggType, fieldName) {
+ getAggLabel(aggType: AGG_TYPE, fieldName: string): string {
switch (aggType) {
case AGG_TYPE.COUNT:
return COUNT_PROP_LABEL;
@@ -81,8 +101,8 @@ export class AbstractESAggSource extends AbstractESSource {
return this.getMetricFields();
}
- getValueAggsDsl(indexPattern) {
- const valueAggsDsl = {};
+ getValueAggsDsl(indexPattern: IndexPattern) {
+ const valueAggsDsl: { [key: string]: unknown } = {};
this.getMetricFields().forEach((esAggMetric) => {
const aggDsl = esAggMetric.getValueAggDsl(indexPattern);
if (aggDsl) {
@@ -92,9 +112,9 @@ export class AbstractESAggSource extends AbstractESSource {
return valueAggsDsl;
}
- async filterAndFormatPropertiesToHtmlForMetricFields(properties) {
- const metricFields = this.getMetricFields();
- const tooltipPropertiesPromises = [];
+ async getTooltipProperties(properties: GeoJsonProperties) {
+ const metricFields = await this.getFields();
+ const promises: Array> = [];
metricFields.forEach((metricField) => {
let value;
for (const key in properties) {
@@ -105,9 +125,9 @@ export class AbstractESAggSource extends AbstractESSource {
}
const tooltipPromise = metricField.createTooltipProperty(value);
- tooltipPropertiesPromises.push(tooltipPromise);
+ promises.push(tooltipPromise);
});
- return await Promise.all(tooltipPropertiesPromises);
+ return await Promise.all(promises);
}
}
diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.d.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.d.ts
index 51ee15e7ea5af..2ce4353fca13c 100644
--- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.d.ts
+++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.d.ts
@@ -7,6 +7,7 @@
import { AbstractESAggSource } from '../es_agg_source';
import { ESGeoGridSourceDescriptor } from '../../../../common/descriptor_types';
import { GRID_RESOLUTION } from '../../../../common/constants';
+import { IField } from '../../fields/field';
export class ESGeoGridSource extends AbstractESAggSource {
static createDescriptor({
@@ -21,4 +22,5 @@ export class ESGeoGridSource extends AbstractESAggSource {
getFieldNames(): string[];
getGridResolution(): GRID_RESOLUTION;
getGeoGridPrecision(zoom: number): number;
+ createField({ fieldName }: { fieldName: string }): IField;
}
diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js
index a6322ff3ba784..aa167cb577672 100644
--- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js
+++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js
@@ -321,10 +321,6 @@ export class ESGeoGridSource extends AbstractESAggSource {
return true;
}
- async filterAndFormatPropertiesToHtml(properties) {
- return await this.filterAndFormatPropertiesToHtmlForMetricFields(properties);
- }
-
async getSupportedShapeTypes() {
if (this._descriptor.requestType === RENDER_AS.GRID) {
return [VECTOR_SHAPE_TYPE.POLYGON];
diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js
index 92b0c717f6724..9ec54335d4e78 100644
--- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js
+++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js
@@ -223,10 +223,6 @@ export class ESPewPewSource extends AbstractESAggSource {
canFormatFeatureProperties() {
return true;
}
-
- async filterAndFormatPropertiesToHtml(properties) {
- return await this.filterAndFormatPropertiesToHtmlForMetricFields(properties);
- }
}
registerSource({
diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js
index 7ac2738eaeb51..df83bd1cf5e60 100644
--- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js
+++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js
@@ -438,7 +438,7 @@ export class ESSearchSource extends AbstractESSource {
return properties;
}
- async filterAndFormatPropertiesToHtml(properties) {
+ async getTooltipProperties(properties) {
const indexPattern = await this.getIndexPattern();
const propertyValues = await this._loadTooltipProperties(
properties._id,
diff --git a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.js b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.js
index 8cc8dd5c4a080..b4ad256c1598a 100644
--- a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.js
+++ b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.js
@@ -129,10 +129,6 @@ export class ESTermSource extends AbstractESAggSource {
return `es_table ${this.getIndexPatternId()}`;
}
- async filterAndFormatPropertiesToHtml(properties) {
- return await this.filterAndFormatPropertiesToHtmlForMetricFields(properties);
- }
-
getFieldNames() {
return this.getMetricFields().map((esAggMetricField) => esAggMetricField.getName());
}
diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.test.tsx b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.test.tsx
index 4e9e1e9cd7680..48f7b30261f38 100644
--- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.test.tsx
+++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.test.tsx
@@ -45,7 +45,7 @@ describe('canFormatFeatureProperties', () => {
});
});
-describe('filterAndFormatPropertiesToHtml', () => {
+describe('getTooltipProperties', () => {
const descriptorWithFields = {
...descriptor,
fields: [
@@ -67,7 +67,7 @@ describe('filterAndFormatPropertiesToHtml', () => {
it('should get tooltipproperties', async () => {
const source = new MVTSingleLayerVectorSource(descriptorWithFields);
- const tooltipProperties = await source.filterAndFormatPropertiesToHtml({
+ const tooltipProperties = await source.getTooltipProperties({
foo: 'bar',
fooz: 123,
});
diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx
index 52dc89a6bba58..3e515613b3fd0 100644
--- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx
+++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx
@@ -192,7 +192,7 @@ export class MVTSingleLayerVectorSource
return false;
}
- async filterAndFormatPropertiesToHtml(
+ async getTooltipProperties(
properties: GeoJsonProperties,
featureId?: string | number
): Promise {
diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts
index fd9c179275444..a481e273bc33e 100644
--- a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts
+++ b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts
@@ -36,7 +36,7 @@ export type BoundsFilters = {
};
export interface IVectorSource extends ISource {
- filterAndFormatPropertiesToHtml(properties: GeoJsonProperties): Promise;
+ getTooltipProperties(properties: GeoJsonProperties): Promise;
getBoundsForFilters(
boundsFilters: BoundsFilters,
registerCancelCallback: (requestToken: symbol, callback: () => void) => void
@@ -58,7 +58,7 @@ export interface IVectorSource extends ISource {
}
export class AbstractVectorSource extends AbstractSource implements IVectorSource {
- filterAndFormatPropertiesToHtml(properties: GeoJsonProperties): Promise;
+ getTooltipProperties(properties: GeoJsonProperties): Promise;
getBoundsForFilters(
boundsFilters: BoundsFilters,
registerCancelCallback: (requestToken: symbol, callback: () => void) => void
diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js
index 98ed89a6ff0ad..9569b8626aabf 100644
--- a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js
+++ b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js
@@ -106,7 +106,7 @@ export class AbstractVectorSource extends AbstractSource {
}
// Allow source to filter and format feature properties before displaying to user
- async filterAndFormatPropertiesToHtml(properties) {
+ async getTooltipProperties(properties) {
const tooltipProperties = [];
for (const key in properties) {
if (key.startsWith('__kbn')) {
diff --git a/x-pack/plugins/maps/public/components/_index.scss b/x-pack/plugins/maps/public/components/_index.scss
index 76ce9f1bc79e3..726573ce4307d 100644
--- a/x-pack/plugins/maps/public/components/_index.scss
+++ b/x-pack/plugins/maps/public/components/_index.scss
@@ -1,4 +1,4 @@
@import 'action_select';
-@import 'metric_editors';
+@import 'metrics_editor/metric_editors';
@import './geometry_filter';
@import 'tooltip_selector/tooltip_selector';
diff --git a/x-pack/plugins/maps/public/components/__snapshots__/metrics_editor.test.js.snap b/x-pack/plugins/maps/public/components/metrics_editor/__snapshots__/metrics_editor.test.tsx.snap
similarity index 92%
rename from x-pack/plugins/maps/public/components/__snapshots__/metrics_editor.test.js.snap
rename to x-pack/plugins/maps/public/components/metrics_editor/__snapshots__/metrics_editor.test.tsx.snap
index 0d4f1f99e464c..bd58ded41e7f5 100644
--- a/x-pack/plugins/maps/public/components/__snapshots__/metrics_editor.test.js.snap
+++ b/x-pack/plugins/maps/public/components/metrics_editor/__snapshots__/metrics_editor.test.tsx.snap
@@ -16,8 +16,9 @@ exports[`should add default count metric when metrics is empty array 1`] = `
"type": "count",
}
}
- metricsFilter={[Function]}
onChange={[Function]}
+ onRemove={[Function]}
+ showRemoveButton={false}
/>
@@ -59,8 +60,9 @@ exports[`should render metrics editor 1`] = `
"type": "sum",
}
}
- metricsFilter={[Function]}
onChange={[Function]}
+ onRemove={[Function]}
+ showRemoveButton={false}
/>
diff --git a/x-pack/plugins/maps/public/components/_metric_editors.scss b/x-pack/plugins/maps/public/components/metrics_editor/_metric_editors.scss
similarity index 100%
rename from x-pack/plugins/maps/public/components/_metric_editors.scss
rename to x-pack/plugins/maps/public/components/metrics_editor/_metric_editors.scss
diff --git a/x-pack/plugins/infra/server/lib/snapshot/constants.ts b/x-pack/plugins/maps/public/components/metrics_editor/index.ts
similarity index 65%
rename from x-pack/plugins/infra/server/lib/snapshot/constants.ts
rename to x-pack/plugins/maps/public/components/metrics_editor/index.ts
index 0420878dbcf50..3c105c2d798ff 100644
--- a/x-pack/plugins/infra/server/lib/snapshot/constants.ts
+++ b/x-pack/plugins/maps/public/components/metrics_editor/index.ts
@@ -4,6 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
-// TODO: Make SNAPSHOT_COMPOSITE_REQUEST_SIZE configurable from kibana.yml
-
-export const SNAPSHOT_COMPOSITE_REQUEST_SIZE = 75;
+export { MetricsEditor } from './metrics_editor';
diff --git a/x-pack/plugins/maps/public/components/metric_editor.js b/x-pack/plugins/maps/public/components/metrics_editor/metric_editor.tsx
similarity index 59%
rename from x-pack/plugins/maps/public/components/metric_editor.js
rename to x-pack/plugins/maps/public/components/metrics_editor/metric_editor.tsx
index 96b52d84653b2..543d144efdcc7 100644
--- a/x-pack/plugins/maps/public/components/metric_editor.js
+++ b/x-pack/plugins/maps/public/components/metrics_editor/metric_editor.tsx
@@ -4,18 +4,20 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
+import React, { ChangeEvent, Fragment } from 'react';
import { i18n } from '@kbn/i18n';
-import { EuiFieldText, EuiFormRow } from '@elastic/eui';
+import { EuiButtonEmpty, EuiComboBoxOptionOption, EuiFieldText, EuiFormRow } from '@elastic/eui';
-import { MetricSelect, METRIC_AGGREGATION_VALUES } from './metric_select';
-import { SingleFieldSelect } from './single_field_select';
-import { AGG_TYPE } from '../../common/constants';
-import { getTermsFields } from '../index_pattern_util';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { MetricSelect } from './metric_select';
+import { SingleFieldSelect } from '../single_field_select';
+import { AggDescriptor } from '../../../common/descriptor_types';
+import { AGG_TYPE } from '../../../common/constants';
+import { getTermsFields } from '../../index_pattern_util';
+import { IFieldType } from '../../../../../../src/plugins/data/public';
-function filterFieldsForAgg(fields, aggType) {
+function filterFieldsForAgg(fields: IFieldType[], aggType: AGG_TYPE) {
if (!fields) {
return [];
}
@@ -34,8 +36,27 @@ function filterFieldsForAgg(fields, aggType) {
});
}
-export function MetricEditor({ fields, metricsFilter, metric, onChange, removeButton }) {
- const onAggChange = (metricAggregationType) => {
+interface Props {
+ metric: AggDescriptor;
+ fields: IFieldType[];
+ onChange: (metric: AggDescriptor) => void;
+ onRemove: () => void;
+ metricsFilter?: (metricOption: EuiComboBoxOptionOption) => boolean;
+ showRemoveButton: boolean;
+}
+
+export function MetricEditor({
+ fields,
+ metricsFilter,
+ metric,
+ onChange,
+ showRemoveButton,
+ onRemove,
+}: Props) {
+ const onAggChange = (metricAggregationType?: AGG_TYPE) => {
+ if (!metricAggregationType) {
+ return;
+ }
const newMetricProps = {
...metric,
type: metricAggregationType,
@@ -54,13 +75,16 @@ export function MetricEditor({ fields, metricsFilter, metric, onChange, removeBu
onChange(newMetricProps);
};
- const onFieldChange = (fieldName) => {
+ const onFieldChange = (fieldName?: string) => {
+ if (!fieldName) {
+ return;
+ }
onChange({
...metric,
field: fieldName,
});
};
- const onLabelChange = (e) => {
+ const onLabelChange = (e: ChangeEvent) => {
onChange({
...metric,
label: e.target.value,
@@ -80,7 +104,7 @@ export function MetricEditor({ fields, metricsFilter, metric, onChange, removeBu
placeholder={i18n.translate('xpack.maps.metricsEditor.selectFieldPlaceholder', {
defaultMessage: 'Select field',
})}
- value={metric.field}
+ value={metric.field ? metric.field : null}
onChange={onFieldChange}
fields={filterFieldsForAgg(fields, metric.type)}
isClearable={false}
@@ -108,6 +132,28 @@ export function MetricEditor({ fields, metricsFilter, metric, onChange, removeBu
);
}
+ let removeButton;
+ if (showRemoveButton) {
+ removeButton = (
+
+
+
+
+
+ );
+ }
+
return (
);
}
-
-MetricEditor.propTypes = {
- metric: PropTypes.shape({
- type: PropTypes.oneOf(METRIC_AGGREGATION_VALUES),
- field: PropTypes.string,
- label: PropTypes.string,
- }),
- fields: PropTypes.array,
- onChange: PropTypes.func.isRequired,
- metricsFilter: PropTypes.func,
-};
diff --git a/x-pack/plugins/maps/public/components/metric_select.js b/x-pack/plugins/maps/public/components/metrics_editor/metric_select.tsx
similarity index 80%
rename from x-pack/plugins/maps/public/components/metric_select.js
rename to x-pack/plugins/maps/public/components/metrics_editor/metric_select.tsx
index 2ebfcf99dece6..197c5466fe0fd 100644
--- a/x-pack/plugins/maps/public/components/metric_select.js
+++ b/x-pack/plugins/maps/public/components/metrics_editor/metric_select.tsx
@@ -5,10 +5,9 @@
*/
import React from 'react';
-import PropTypes from 'prop-types';
import { i18n } from '@kbn/i18n';
-import { EuiComboBox } from '@elastic/eui';
-import { AGG_TYPE } from '../../common/constants';
+import { EuiComboBox, EuiComboBoxOptionOption, EuiComboBoxProps } from '@elastic/eui';
+import { AGG_TYPE } from '../../../common/constants';
const AGG_OPTIONS = [
{
@@ -55,17 +54,19 @@ const AGG_OPTIONS = [
},
];
-export const METRIC_AGGREGATION_VALUES = AGG_OPTIONS.map(({ value }) => {
- return value;
-});
+type Props = Omit, 'onChange'> & {
+ value: AGG_TYPE;
+ onChange: (aggType: AGG_TYPE) => void;
+ metricsFilter?: (metricOption: EuiComboBoxOptionOption) => boolean;
+};
-export function MetricSelect({ value, onChange, metricsFilter, ...rest }) {
- function onAggChange(selectedOptions) {
+export function MetricSelect({ value, onChange, metricsFilter, ...rest }: Props) {
+ function onAggChange(selectedOptions: Array>) {
if (selectedOptions.length === 0) {
return;
}
- const aggType = selectedOptions[0].value;
+ const aggType = selectedOptions[0].value!;
onChange(aggType);
}
@@ -87,9 +88,3 @@ export function MetricSelect({ value, onChange, metricsFilter, ...rest }) {
/>
);
}
-
-MetricSelect.propTypes = {
- metricsFilter: PropTypes.func,
- value: PropTypes.oneOf(METRIC_AGGREGATION_VALUES),
- onChange: PropTypes.func.isRequired,
-};
diff --git a/x-pack/plugins/maps/public/components/metrics_editor.test.js b/x-pack/plugins/maps/public/components/metrics_editor/metrics_editor.test.tsx
similarity index 84%
rename from x-pack/plugins/maps/public/components/metrics_editor.test.js
rename to x-pack/plugins/maps/public/components/metrics_editor/metrics_editor.test.tsx
index bcbeef29875ee..7ce7fbce2b066 100644
--- a/x-pack/plugins/maps/public/components/metrics_editor.test.js
+++ b/x-pack/plugins/maps/public/components/metrics_editor/metrics_editor.test.tsx
@@ -7,7 +7,7 @@
import React from 'react';
import { shallow } from 'enzyme';
import { MetricsEditor } from './metrics_editor';
-import { AGG_TYPE } from '../../common/constants';
+import { AGG_TYPE } from '../../../common/constants';
const defaultProps = {
metrics: [
@@ -19,15 +19,14 @@ const defaultProps = {
fields: [],
onChange: () => {},
allowMultipleMetrics: true,
- metricsFilter: () => {},
};
-test('should render metrics editor', async () => {
+test('should render metrics editor', () => {
const component = shallow();
expect(component).toMatchSnapshot();
});
-test('should add default count metric when metrics is empty array', async () => {
+test('should add default count metric when metrics is empty array', () => {
const component = shallow();
expect(component).toMatchSnapshot();
});
diff --git a/x-pack/plugins/maps/public/components/metrics_editor.js b/x-pack/plugins/maps/public/components/metrics_editor/metrics_editor.tsx
similarity index 54%
rename from x-pack/plugins/maps/public/components/metrics_editor.js
rename to x-pack/plugins/maps/public/components/metrics_editor/metrics_editor.tsx
index 7d4d7bf3ec7ab..dae1f51469281 100644
--- a/x-pack/plugins/maps/public/components/metrics_editor.js
+++ b/x-pack/plugins/maps/public/components/metrics_editor/metrics_editor.tsx
@@ -5,48 +5,42 @@
*/
import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
-import { EuiButtonEmpty, EuiSpacer, EuiTextAlign } from '@elastic/eui';
+import { EuiButtonEmpty, EuiComboBoxOptionOption, EuiSpacer, EuiTextAlign } from '@elastic/eui';
import { MetricEditor } from './metric_editor';
-import { DEFAULT_METRIC } from '../classes/sources/es_agg_source';
+import { DEFAULT_METRIC } from '../../classes/sources/es_agg_source';
+import { IFieldType } from '../../../../../../src/plugins/data/public';
+import { AggDescriptor } from '../../../common/descriptor_types';
+import { AGG_TYPE } from '../../../common/constants';
-export function MetricsEditor({ fields, metrics, onChange, allowMultipleMetrics, metricsFilter }) {
+interface Props {
+ allowMultipleMetrics: boolean;
+ metrics: AggDescriptor[];
+ fields: IFieldType[];
+ onChange: (metrics: AggDescriptor[]) => void;
+ metricsFilter?: (metricOption: EuiComboBoxOptionOption) => boolean;
+}
+
+export function MetricsEditor({
+ fields,
+ metrics = [DEFAULT_METRIC],
+ onChange,
+ allowMultipleMetrics = true,
+ metricsFilter,
+}: Props) {
function renderMetrics() {
// There was a bug in 7.8 that initialized metrics to [].
// This check is needed to handle any saved objects created before the bug was patched.
const nonEmptyMetrics = metrics.length === 0 ? [DEFAULT_METRIC] : metrics;
return nonEmptyMetrics.map((metric, index) => {
- const onMetricChange = (metric) => {
- onChange([...metrics.slice(0, index), metric, ...metrics.slice(index + 1)]);
+ const onMetricChange = (updatedMetric: AggDescriptor) => {
+ onChange([...metrics.slice(0, index), updatedMetric, ...metrics.slice(index + 1)]);
};
const onRemove = () => {
onChange([...metrics.slice(0, index), ...metrics.slice(index + 1)]);
};
- let removeButton;
- if (index > 0) {
- removeButton = (
-
-
-
-
-
- );
- }
return (
0}
+ onRemove={onRemove}
/>
);
@@ -62,7 +57,7 @@ export function MetricsEditor({ fields, metrics, onChange, allowMultipleMetrics,
}
function addMetric() {
- onChange([...metrics, {}]);
+ onChange([...metrics, { type: AGG_TYPE.AVG }]);
}
function renderAddMetricButton() {
@@ -71,7 +66,7 @@ export function MetricsEditor({ fields, metrics, onChange, allowMultipleMetrics,
}
return (
- <>
+
@@ -81,7 +76,7 @@ export function MetricsEditor({ fields, metrics, onChange, allowMultipleMetrics,
/>
- >
+
);
}
@@ -93,16 +88,3 @@ export function MetricsEditor({ fields, metrics, onChange, allowMultipleMetrics,
);
}
-
-MetricsEditor.propTypes = {
- metrics: PropTypes.array,
- fields: PropTypes.array,
- onChange: PropTypes.func.isRequired,
- allowMultipleMetrics: PropTypes.bool,
- metricsFilter: PropTypes.func,
-};
-
-MetricsEditor.defaultProps = {
- metrics: [DEFAULT_METRIC],
- allowMultipleMetrics: true,
-};
diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/metrics_expression.test.js b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/metrics_expression.test.js
index 3cd8a3c42879a..e0e1556ecde06 100644
--- a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/metrics_expression.test.js
+++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/metrics_expression.test.js
@@ -4,12 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
-jest.mock('../../../../components/metric_editor', () => ({
- MetricsEditor: () => {
- return mockMetricsEditor
;
- },
-}));
-
import React from 'react';
import { shallow } from 'enzyme';
import { MetricsExpression } from './metrics_expression';
diff --git a/x-pack/plugins/ml/common/constants/data_frame_analytics.ts b/x-pack/plugins/ml/common/constants/data_frame_analytics.ts
new file mode 100644
index 0000000000000..830537cbadbc8
--- /dev/null
+++ b/x-pack/plugins/ml/common/constants/data_frame_analytics.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export const DEFAULT_RESULTS_FIELD = 'ml';
diff --git a/x-pack/plugins/ml/common/types/__mocks__/job_config_farequote.json b/x-pack/plugins/ml/common/types/__mocks__/job_config_farequote.json
index 18a49bb3841b3..6bc0e55b5aadd 100644
--- a/x-pack/plugins/ml/common/types/__mocks__/job_config_farequote.json
+++ b/x-pack/plugins/ml/common/types/__mocks__/job_config_farequote.json
@@ -69,9 +69,7 @@
"datafeed_id": "datafeed-farequote",
"job_id": "farequote",
"query_delay": "115823ms",
- "indices": [
- "farequote"
- ],
+ "indices": ["farequote"],
"query": {
"bool": {
"must": [
@@ -103,7 +101,7 @@
"buckets": {
"date_histogram": {
"field": "@timestamp",
- "interval": 900000,
+ "fixed_interval": "15m",
"offset": 0,
"order": {
"_key": "asc"
diff --git a/x-pack/plugins/ml/common/types/data_frame_analytics.ts b/x-pack/plugins/ml/common/types/data_frame_analytics.ts
index f0aac75047585..60d2ca63dda59 100644
--- a/x-pack/plugins/ml/common/types/data_frame_analytics.ts
+++ b/x-pack/plugins/ml/common/types/data_frame_analytics.ts
@@ -79,3 +79,9 @@ export interface DataFrameAnalyticsConfig {
version: string;
allow_lazy_start?: boolean;
}
+
+export enum ANALYSIS_CONFIG_TYPE {
+ OUTLIER_DETECTION = 'outlier_detection',
+ REGRESSION = 'regression',
+ CLASSIFICATION = 'classification',
+}
diff --git a/x-pack/plugins/ml/common/types/feature_importance.ts b/x-pack/plugins/ml/common/types/feature_importance.ts
new file mode 100644
index 0000000000000..d2ab9f6c58608
--- /dev/null
+++ b/x-pack/plugins/ml/common/types/feature_importance.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export interface ClassFeatureImportance {
+ class_name: string | boolean;
+ importance: number;
+}
+export interface FeatureImportance {
+ feature_name: string;
+ importance?: number;
+ classes?: ClassFeatureImportance[];
+}
+
+export interface TopClass {
+ class_name: string;
+ class_probability: number;
+ class_score: number;
+}
+
+export type TopClasses = TopClass[];
diff --git a/x-pack/plugins/ml/common/types/file_datavisualizer.ts b/x-pack/plugins/ml/common/types/file_datavisualizer.ts
index c997a4e24f868..a8b775c8d5f60 100644
--- a/x-pack/plugins/ml/common/types/file_datavisualizer.ts
+++ b/x-pack/plugins/ml/common/types/file_datavisualizer.ts
@@ -84,7 +84,12 @@ export interface Settings {
}
export interface Mappings {
- [key: string]: any;
+ _meta?: {
+ created_by: string;
+ };
+ properties: {
+ [key: string]: any;
+ };
}
export interface IngestPipelineWrapper {
diff --git a/x-pack/plugins/ml/common/util/analytics_utils.ts b/x-pack/plugins/ml/common/util/analytics_utils.ts
new file mode 100644
index 0000000000000..d725984a47d66
--- /dev/null
+++ b/x-pack/plugins/ml/common/util/analytics_utils.ts
@@ -0,0 +1,79 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ AnalysisConfig,
+ ClassificationAnalysis,
+ OutlierAnalysis,
+ RegressionAnalysis,
+ ANALYSIS_CONFIG_TYPE,
+} from '../types/data_frame_analytics';
+
+export const isOutlierAnalysis = (arg: any): arg is OutlierAnalysis => {
+ const keys = Object.keys(arg);
+ return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION;
+};
+
+export const isRegressionAnalysis = (arg: any): arg is RegressionAnalysis => {
+ const keys = Object.keys(arg);
+ return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.REGRESSION;
+};
+
+export const isClassificationAnalysis = (arg: any): arg is ClassificationAnalysis => {
+ const keys = Object.keys(arg);
+ return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.CLASSIFICATION;
+};
+
+export const getDependentVar = (
+ analysis: AnalysisConfig
+):
+ | RegressionAnalysis['regression']['dependent_variable']
+ | ClassificationAnalysis['classification']['dependent_variable'] => {
+ let depVar = '';
+
+ if (isRegressionAnalysis(analysis)) {
+ depVar = analysis.regression.dependent_variable;
+ }
+
+ if (isClassificationAnalysis(analysis)) {
+ depVar = analysis.classification.dependent_variable;
+ }
+ return depVar;
+};
+
+export const getPredictionFieldName = (
+ analysis: AnalysisConfig
+):
+ | RegressionAnalysis['regression']['prediction_field_name']
+ | ClassificationAnalysis['classification']['prediction_field_name'] => {
+ // If undefined will be defaulted to dependent_variable when config is created
+ let predictionFieldName;
+ if (isRegressionAnalysis(analysis) && analysis.regression.prediction_field_name !== undefined) {
+ predictionFieldName = analysis.regression.prediction_field_name;
+ } else if (
+ isClassificationAnalysis(analysis) &&
+ analysis.classification.prediction_field_name !== undefined
+ ) {
+ predictionFieldName = analysis.classification.prediction_field_name;
+ }
+ return predictionFieldName;
+};
+
+export const getDefaultPredictionFieldName = (analysis: AnalysisConfig) => {
+ return `${getDependentVar(analysis)}_prediction`;
+};
+export const getPredictedFieldName = (
+ resultsField: string,
+ analysis: AnalysisConfig,
+ forSort?: boolean
+) => {
+ // default is 'ml'
+ const predictionFieldName = getPredictionFieldName(analysis);
+ const predictedField = `${resultsField}.${
+ predictionFieldName ? predictionFieldName : getDefaultPredictionFieldName(analysis)
+ }`;
+ return predictedField;
+};
diff --git a/x-pack/plugins/ml/public/application/components/data_grid/common.ts b/x-pack/plugins/ml/public/application/components/data_grid/common.ts
index 1f0fcb63f019d..f252729cc20cd 100644
--- a/x-pack/plugins/ml/public/application/components/data_grid/common.ts
+++ b/x-pack/plugins/ml/public/application/components/data_grid/common.ts
@@ -119,13 +119,14 @@ export const getDataGridSchemasFromFieldTypes = (fieldTypes: FieldTypes, results
schema = 'numeric';
}
- if (
- field.includes(`${resultsField}.${FEATURE_IMPORTANCE}`) ||
- field.includes(`${resultsField}.${TOP_CLASSES}`)
- ) {
+ if (field.includes(`${resultsField}.${TOP_CLASSES}`)) {
schema = 'json';
}
+ if (field.includes(`${resultsField}.${FEATURE_IMPORTANCE}`)) {
+ schema = 'featureImportance';
+ }
+
return { id: field, schema, isSortable };
});
};
@@ -250,10 +251,6 @@ export const useRenderCellValue = (
return cellValue ? 'true' : 'false';
}
- if (typeof cellValue === 'object' && cellValue !== null) {
- return JSON.stringify(cellValue);
- }
-
return cellValue;
};
}, [indexPattern?.fields, pagination.pageIndex, pagination.pageSize, tableItems]);
diff --git a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx
index d4be2eab13d26..22815fe593d57 100644
--- a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx
+++ b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx
@@ -5,8 +5,7 @@
*/
import { isEqual } from 'lodash';
-import React, { memo, useEffect, FC } from 'react';
-
+import React, { memo, useEffect, FC, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import {
@@ -24,13 +23,16 @@ import {
} from '@elastic/eui';
import { CoreSetup } from 'src/core/public';
-
import { DEFAULT_SAMPLER_SHARD_SIZE } from '../../../../common/constants/field_histograms';
-import { INDEX_STATUS } from '../../data_frame_analytics/common';
+import { ANALYSIS_CONFIG_TYPE, INDEX_STATUS } from '../../data_frame_analytics/common';
import { euiDataGridStyle, euiDataGridToolbarSettings } from './common';
import { UseIndexDataReturnType } from './types';
+import { DecisionPathPopover } from './feature_importance/decision_path_popover';
+import { TopClasses } from '../../../../common/types/feature_importance';
+import { DEFAULT_RESULTS_FIELD } from '../../../../common/constants/data_frame_analytics';
+
// TODO Fix row hovering + bar highlighting
// import { hoveredRow$ } from './column_chart';
@@ -41,6 +43,9 @@ export const DataGridTitle: FC<{ title: string }> = ({ title }) => (
);
interface PropsWithoutHeader extends UseIndexDataReturnType {
+ baseline?: number;
+ analysisType?: ANALYSIS_CONFIG_TYPE;
+ resultsField?: string;
dataTestSubj: string;
toastNotifications: CoreSetup['notifications']['toasts'];
}
@@ -60,6 +65,7 @@ type Props = PropsWithHeader | PropsWithoutHeader;
export const DataGrid: FC = memo(
(props) => {
const {
+ baseline,
chartsVisible,
chartsButtonVisible,
columnsWithCharts,
@@ -80,8 +86,10 @@ export const DataGrid: FC = memo(
toastNotifications,
toggleChartVisibility,
visibleColumns,
+ predictionFieldName,
+ resultsField,
+ analysisType,
} = props;
-
// TODO Fix row hovering + bar highlighting
// const getRowProps = (item: any) => {
// return {
@@ -90,6 +98,45 @@ export const DataGrid: FC = memo(
// };
// };
+ const popOverContent = useMemo(() => {
+ return analysisType === ANALYSIS_CONFIG_TYPE.REGRESSION ||
+ analysisType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION
+ ? {
+ featureImportance: ({ children }: { cellContentsElement: any; children: any }) => {
+ const rowIndex = children?.props?.visibleRowIndex;
+ const row = data[rowIndex];
+ if (!row) return ;
+ // if resultsField for some reason is not available then use ml
+ const mlResultsField = resultsField ?? DEFAULT_RESULTS_FIELD;
+ const parsedFIArray = row[mlResultsField].feature_importance;
+ let predictedValue: string | number | undefined;
+ let topClasses: TopClasses = [];
+ if (
+ predictionFieldName !== undefined &&
+ row &&
+ row[mlResultsField][predictionFieldName] !== undefined
+ ) {
+ predictedValue = row[mlResultsField][predictionFieldName];
+ topClasses = row[mlResultsField].top_classes;
+ }
+
+ return (
+
+ );
+ },
+ }
+ : undefined;
+ }, [baseline, data]);
+
useEffect(() => {
if (invalidSortingColumnns.length > 0) {
invalidSortingColumnns.forEach((columnId) => {
@@ -225,6 +272,7 @@ export const DataGrid: FC = memo(
}
: {}),
}}
+ popoverContents={popOverContent}
pagination={{
...pagination,
pageSizeOptions: [5, 10, 25],
diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_chart.tsx b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_chart.tsx
new file mode 100644
index 0000000000000..b546ac1db57dd
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_chart.tsx
@@ -0,0 +1,166 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {
+ AnnotationDomainTypes,
+ Axis,
+ AxisStyle,
+ Chart,
+ LineAnnotation,
+ LineAnnotationStyle,
+ LineAnnotationDatum,
+ LineSeries,
+ PartialTheme,
+ Position,
+ RecursivePartial,
+ ScaleType,
+ Settings,
+} from '@elastic/charts';
+import { EuiIcon } from '@elastic/eui';
+
+import React, { useCallback, useMemo } from 'react';
+import { i18n } from '@kbn/i18n';
+import euiVars from '@elastic/eui/dist/eui_theme_light.json';
+import { DecisionPathPlotData } from './use_classification_path_data';
+
+const { euiColorFullShade, euiColorMediumShade } = euiVars;
+const axisColor = euiColorMediumShade;
+
+const baselineStyle: LineAnnotationStyle = {
+ line: {
+ strokeWidth: 1,
+ stroke: euiColorFullShade,
+ opacity: 0.75,
+ },
+ details: {
+ fontFamily: 'Arial',
+ fontSize: 10,
+ fontStyle: 'bold',
+ fill: euiColorMediumShade,
+ padding: 0,
+ },
+};
+
+const axes: RecursivePartial = {
+ axisLine: {
+ stroke: axisColor,
+ },
+ tickLabel: {
+ fontSize: 10,
+ fill: axisColor,
+ },
+ tickLine: {
+ stroke: axisColor,
+ },
+ gridLine: {
+ horizontal: {
+ dash: [1, 2],
+ },
+ vertical: {
+ strokeWidth: 0,
+ },
+ },
+};
+const theme: PartialTheme = {
+ axes,
+};
+
+interface DecisionPathChartProps {
+ decisionPathData: DecisionPathPlotData;
+ predictionFieldName?: string;
+ baseline?: number;
+ minDomain: number | undefined;
+ maxDomain: number | undefined;
+}
+
+const DECISION_PATH_MARGIN = 125;
+const DECISION_PATH_ROW_HEIGHT = 10;
+const NUM_PRECISION = 3;
+const AnnotationBaselineMarker = ;
+
+export const DecisionPathChart = ({
+ decisionPathData,
+ predictionFieldName,
+ minDomain,
+ maxDomain,
+ baseline,
+}: DecisionPathChartProps) => {
+ // adjust the height so it's compact for items with more features
+ const baselineData: LineAnnotationDatum[] = useMemo(
+ () => [
+ {
+ dataValue: baseline,
+ header: baseline ? baseline.toPrecision(NUM_PRECISION) : '',
+ details: i18n.translate(
+ 'xpack.ml.dataframe.analytics.explorationResults.decisionPathBaselineText',
+ {
+ defaultMessage:
+ 'baseline (average of predictions for all data points in the training data set)',
+ }
+ ),
+ },
+ ],
+ [baseline]
+ );
+ // guarantee up to num_precision significant digits
+ // without having it in scientific notation
+ const tickFormatter = useCallback((d) => Number(d.toPrecision(NUM_PRECISION)).toString(), []);
+
+ return (
+
+
+ {baseline && (
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_classification.tsx b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_classification.tsx
new file mode 100644
index 0000000000000..bd001fa81a582
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_classification.tsx
@@ -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 React, { FC, useMemo, useState } from 'react';
+import { i18n } from '@kbn/i18n';
+import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiTitle } from '@elastic/eui';
+import d3 from 'd3';
+import {
+ isDecisionPathData,
+ useDecisionPathData,
+ getStringBasedClassName,
+} from './use_classification_path_data';
+import { FeatureImportance, TopClasses } from '../../../../../common/types/feature_importance';
+import { DecisionPathChart } from './decision_path_chart';
+import { MissingDecisionPathCallout } from './missing_decision_path_callout';
+
+interface ClassificationDecisionPathProps {
+ predictedValue: string | boolean;
+ predictionFieldName?: string;
+ featureImportance: FeatureImportance[];
+ topClasses: TopClasses;
+}
+
+export const ClassificationDecisionPath: FC = ({
+ featureImportance,
+ predictedValue,
+ topClasses,
+ predictionFieldName,
+}) => {
+ const [currentClass, setCurrentClass] = useState(
+ getStringBasedClassName(topClasses[0].class_name)
+ );
+ const { decisionPathData } = useDecisionPathData({
+ featureImportance,
+ predictedValue: currentClass,
+ });
+ const options = useMemo(() => {
+ const predictionValueStr = getStringBasedClassName(predictedValue);
+
+ return Array.isArray(topClasses)
+ ? topClasses.map((c) => {
+ const className = getStringBasedClassName(c.class_name);
+ return {
+ value: className,
+ inputDisplay:
+ className === predictionValueStr ? (
+
+ {className}
+
+ ) : (
+ className
+ ),
+ };
+ })
+ : undefined;
+ }, [topClasses, predictedValue]);
+
+ const domain = useMemo(() => {
+ let maxDomain;
+ let minDomain;
+ // if decisionPathData has calculated cumulative path
+ if (decisionPathData && isDecisionPathData(decisionPathData)) {
+ const [min, max] = d3.extent(decisionPathData, (d: [string, number, number]) => d[2]);
+ const buffer = Math.abs(max - min) * 0.1;
+ maxDomain = max + buffer;
+ minDomain = min - buffer;
+ }
+ return { maxDomain, minDomain };
+ }, [decisionPathData]);
+
+ if (!decisionPathData) return ;
+
+ return (
+ <>
+
+
+
+ {i18n.translate(
+ 'xpack.ml.dataframe.analytics.explorationResults.classificationDecisionPathClassNameTitle',
+ {
+ defaultMessage: 'Class name',
+ }
+ )}
+
+
+ {options !== undefined && (
+
+ )}
+
+ >
+ );
+};
diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_json_viewer.tsx b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_json_viewer.tsx
new file mode 100644
index 0000000000000..343324b27f9b5
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_json_viewer.tsx
@@ -0,0 +1,16 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FC } from 'react';
+import { EuiCodeBlock } from '@elastic/eui';
+import { FeatureImportance } from '../../../../../common/types/feature_importance';
+
+interface DecisionPathJSONViewerProps {
+ featureImportance: FeatureImportance[];
+}
+export const DecisionPathJSONViewer: FC = ({ featureImportance }) => {
+ return {JSON.stringify(featureImportance)};
+};
diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx
new file mode 100644
index 0000000000000..263337f93e9a8
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx
@@ -0,0 +1,134 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FC, useState } from 'react';
+import { EuiLink, EuiTab, EuiTabs, EuiText } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { RegressionDecisionPath } from './decision_path_regression';
+import { DecisionPathJSONViewer } from './decision_path_json_viewer';
+import { FeatureImportance, TopClasses } from '../../../../../common/types/feature_importance';
+import { ANALYSIS_CONFIG_TYPE } from '../../../data_frame_analytics/common';
+import { ClassificationDecisionPath } from './decision_path_classification';
+import { useMlKibana } from '../../../contexts/kibana';
+
+interface DecisionPathPopoverProps {
+ featureImportance: FeatureImportance[];
+ analysisType: ANALYSIS_CONFIG_TYPE;
+ predictionFieldName?: string;
+ baseline?: number;
+ predictedValue?: number | string | undefined;
+ topClasses?: TopClasses;
+}
+
+enum DECISION_PATH_TABS {
+ CHART = 'decision_path_chart',
+ JSON = 'decision_path_json',
+}
+
+export interface ExtendedFeatureImportance extends FeatureImportance {
+ absImportance?: number;
+}
+
+export const DecisionPathPopover: FC = ({
+ baseline,
+ featureImportance,
+ predictedValue,
+ topClasses,
+ analysisType,
+ predictionFieldName,
+}) => {
+ const [selectedTabId, setSelectedTabId] = useState(DECISION_PATH_TABS.CHART);
+ const {
+ services: { docLinks },
+ } = useMlKibana();
+ const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks;
+
+ if (featureImportance.length < 2) {
+ return ;
+ }
+
+ const tabs = [
+ {
+ id: DECISION_PATH_TABS.CHART,
+ name: (
+
+ ),
+ },
+ {
+ id: DECISION_PATH_TABS.JSON,
+ name: (
+
+ ),
+ },
+ ];
+
+ return (
+ <>
+
+
+ {tabs.map((tab) => (
+ setSelectedTabId(tab.id)}
+ key={tab.id}
+ >
+ {tab.name}
+
+ ))}
+
+
+ {selectedTabId === DECISION_PATH_TABS.CHART && (
+ <>
+
+
+
+
+ ),
+ }}
+ />
+
+ {analysisType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && (
+
+ )}
+ {analysisType === ANALYSIS_CONFIG_TYPE.REGRESSION && (
+
+ )}
+ >
+ )}
+ {selectedTabId === DECISION_PATH_TABS.JSON && (
+
+ )}
+ >
+ );
+};
diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_regression.tsx b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_regression.tsx
new file mode 100644
index 0000000000000..345269a944f02
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_regression.tsx
@@ -0,0 +1,79 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FC, useMemo } from 'react';
+import { EuiCallOut } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import d3 from 'd3';
+import { FeatureImportance, TopClasses } from '../../../../../common/types/feature_importance';
+import { useDecisionPathData, isDecisionPathData } from './use_classification_path_data';
+import { DecisionPathChart } from './decision_path_chart';
+import { MissingDecisionPathCallout } from './missing_decision_path_callout';
+
+interface RegressionDecisionPathProps {
+ predictionFieldName?: string;
+ baseline?: number;
+ predictedValue?: number | undefined;
+ featureImportance: FeatureImportance[];
+ topClasses?: TopClasses;
+}
+
+export const RegressionDecisionPath: FC = ({
+ baseline,
+ featureImportance,
+ predictedValue,
+ predictionFieldName,
+}) => {
+ const { decisionPathData } = useDecisionPathData({
+ baseline,
+ featureImportance,
+ predictedValue,
+ });
+ const domain = useMemo(() => {
+ let maxDomain;
+ let minDomain;
+ // if decisionPathData has calculated cumulative path
+ if (decisionPathData && isDecisionPathData(decisionPathData)) {
+ const [min, max] = d3.extent(decisionPathData, (d: [string, number, number]) => d[2]);
+ maxDomain = max;
+ minDomain = min;
+ const buffer = Math.abs(maxDomain - minDomain) * 0.1;
+ maxDomain =
+ (typeof baseline === 'number' ? Math.max(maxDomain, baseline) : maxDomain) + buffer;
+ minDomain =
+ (typeof baseline === 'number' ? Math.min(minDomain, baseline) : minDomain) - buffer;
+ }
+ return { maxDomain, minDomain };
+ }, [decisionPathData, baseline]);
+
+ if (!decisionPathData) return ;
+
+ return (
+ <>
+ {baseline === undefined && (
+
+ }
+ color="warning"
+ iconType="alert"
+ />
+ )}
+
+ >
+ );
+};
diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/missing_decision_path_callout.tsx b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/missing_decision_path_callout.tsx
new file mode 100644
index 0000000000000..66eb2047b1314
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/missing_decision_path_callout.tsx
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { EuiCallOut } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+export const MissingDecisionPathCallout = () => {
+ return (
+
+
+
+ );
+};
diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/use_classification_path_data.tsx b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/use_classification_path_data.tsx
new file mode 100644
index 0000000000000..90216c4a58ffc
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/use_classification_path_data.tsx
@@ -0,0 +1,173 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { useMemo } from 'react';
+import { i18n } from '@kbn/i18n';
+import { FeatureImportance, TopClasses } from '../../../../../common/types/feature_importance';
+import { ExtendedFeatureImportance } from './decision_path_popover';
+
+export type DecisionPathPlotData = Array<[string, number, number]>;
+
+interface UseDecisionPathDataParams {
+ featureImportance: FeatureImportance[];
+ baseline?: number;
+ predictedValue?: string | number | undefined;
+ topClasses?: TopClasses;
+}
+
+interface RegressionDecisionPathProps {
+ baseline?: number;
+ predictedValue?: number | undefined;
+ featureImportance: FeatureImportance[];
+ topClasses?: TopClasses;
+}
+const FEATURE_NAME = 'feature_name';
+const FEATURE_IMPORTANCE = 'importance';
+
+export const isDecisionPathData = (decisionPathData: any): boolean => {
+ return (
+ Array.isArray(decisionPathData) &&
+ decisionPathData.length > 0 &&
+ decisionPathData[0].length === 3
+ );
+};
+
+// cast to 'True' | 'False' | value to match Eui display
+export const getStringBasedClassName = (v: string | boolean | undefined | number): string => {
+ if (v === undefined) {
+ return '';
+ }
+ if (typeof v === 'boolean') {
+ return v ? 'True' : 'False';
+ }
+ if (typeof v === 'number') {
+ return v.toString();
+ }
+ return v;
+};
+
+export const useDecisionPathData = ({
+ baseline,
+ featureImportance,
+ predictedValue,
+}: UseDecisionPathDataParams): { decisionPathData: DecisionPathPlotData | undefined } => {
+ const decisionPathData = useMemo(() => {
+ return baseline
+ ? buildRegressionDecisionPathData({
+ baseline,
+ featureImportance,
+ predictedValue: predictedValue as number | undefined,
+ })
+ : buildClassificationDecisionPathData({
+ featureImportance,
+ currentClass: predictedValue as string | undefined,
+ });
+ }, [baseline, featureImportance, predictedValue]);
+
+ return { decisionPathData };
+};
+
+export const buildDecisionPathData = (featureImportance: ExtendedFeatureImportance[]) => {
+ const finalResult: DecisionPathPlotData = featureImportance
+ // sort so absolute importance so it goes from bottom (baseline) to top
+ .sort(
+ (a: ExtendedFeatureImportance, b: ExtendedFeatureImportance) =>
+ b.absImportance! - a.absImportance!
+ )
+ .map((d) => [d[FEATURE_NAME] as string, d[FEATURE_IMPORTANCE] as number, NaN]);
+
+ // start at the baseline and end at predicted value
+ // for regression, cumulativeSum should add up to baseline
+ let cumulativeSum = 0;
+ for (let i = featureImportance.length - 1; i >= 0; i--) {
+ cumulativeSum += finalResult[i][1];
+ finalResult[i][2] = cumulativeSum;
+ }
+ return finalResult;
+};
+export const buildRegressionDecisionPathData = ({
+ baseline,
+ featureImportance,
+ predictedValue,
+}: RegressionDecisionPathProps): DecisionPathPlotData | undefined => {
+ let mappedFeatureImportance: ExtendedFeatureImportance[] = featureImportance;
+ mappedFeatureImportance = mappedFeatureImportance.map((d) => ({
+ ...d,
+ absImportance: Math.abs(d[FEATURE_IMPORTANCE] as number),
+ }));
+
+ if (baseline && predictedValue !== undefined && Number.isFinite(predictedValue)) {
+ // get the adjusted importance needed for when # of fields included in c++ analysis != max allowed
+ // if num fields included = num features allowed exactly, adjustedImportance should be 0
+ const adjustedImportance =
+ predictedValue -
+ mappedFeatureImportance.reduce(
+ (accumulator, currentValue) => accumulator + currentValue.importance!,
+ 0
+ ) -
+ baseline;
+
+ mappedFeatureImportance.push({
+ [FEATURE_NAME]: i18n.translate(
+ 'xpack.ml.dataframe.analytics.decisionPathFeatureBaselineTitle',
+ {
+ defaultMessage: 'baseline',
+ }
+ ),
+ [FEATURE_IMPORTANCE]: baseline,
+ absImportance: -1,
+ });
+
+ // if the difference is small enough then no need to plot the residual feature importance
+ if (Math.abs(adjustedImportance) > 1e-5) {
+ mappedFeatureImportance.push({
+ [FEATURE_NAME]: i18n.translate(
+ 'xpack.ml.dataframe.analytics.decisionPathFeatureOtherTitle',
+ {
+ defaultMessage: 'other',
+ }
+ ),
+ [FEATURE_IMPORTANCE]: adjustedImportance,
+ absImportance: 0, // arbitrary importance so this will be of higher importance than baseline
+ });
+ }
+ }
+ const filteredFeatureImportance = mappedFeatureImportance.filter(
+ (f) => f !== undefined
+ ) as ExtendedFeatureImportance[];
+
+ return buildDecisionPathData(filteredFeatureImportance);
+};
+
+export const buildClassificationDecisionPathData = ({
+ featureImportance,
+ currentClass,
+}: {
+ featureImportance: FeatureImportance[];
+ currentClass: string | undefined;
+}): DecisionPathPlotData | undefined => {
+ if (currentClass === undefined) return [];
+ const mappedFeatureImportance: Array<
+ ExtendedFeatureImportance | undefined
+ > = featureImportance.map((feature) => {
+ const classFeatureImportance = Array.isArray(feature.classes)
+ ? feature.classes.find((c) => getStringBasedClassName(c.class_name) === currentClass)
+ : feature;
+ if (classFeatureImportance && typeof classFeatureImportance[FEATURE_IMPORTANCE] === 'number') {
+ return {
+ [FEATURE_NAME]: feature[FEATURE_NAME],
+ [FEATURE_IMPORTANCE]: classFeatureImportance[FEATURE_IMPORTANCE],
+ absImportance: Math.abs(classFeatureImportance[FEATURE_IMPORTANCE] as number),
+ };
+ }
+ return undefined;
+ });
+ const filteredFeatureImportance = mappedFeatureImportance.filter(
+ (f) => f !== undefined
+ ) as ExtendedFeatureImportance[];
+
+ return buildDecisionPathData(filteredFeatureImportance);
+};
diff --git a/x-pack/plugins/ml/public/application/components/data_grid/types.ts b/x-pack/plugins/ml/public/application/components/data_grid/types.ts
index 756f74c8f9302..f9ee8c37fabf7 100644
--- a/x-pack/plugins/ml/public/application/components/data_grid/types.ts
+++ b/x-pack/plugins/ml/public/application/components/data_grid/types.ts
@@ -74,6 +74,9 @@ export interface UseIndexDataReturnType
| 'tableItems'
| 'toggleChartVisibility'
| 'visibleColumns'
+ | 'baseline'
+ | 'predictionFieldName'
+ | 'resultsField'
> {
renderCellValue: RenderCellValue;
}
@@ -105,4 +108,7 @@ export interface UseDataGridReturnType {
tableItems: DataGridItem[];
toggleChartVisibility: () => void;
visibleColumns: ColumnId[];
+ baseline?: number;
+ predictionFieldName?: string;
+ resultsField?: string;
}
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts
index 8ad861e616b7a..97098ea9e75c6 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts
@@ -15,18 +15,19 @@ import { SavedSearchQuery } from '../../contexts/ml';
import {
AnalysisConfig,
ClassificationAnalysis,
- OutlierAnalysis,
RegressionAnalysis,
+ ANALYSIS_CONFIG_TYPE,
} from '../../../../common/types/data_frame_analytics';
-
+import {
+ isOutlierAnalysis,
+ isRegressionAnalysis,
+ isClassificationAnalysis,
+ getPredictionFieldName,
+ getDependentVar,
+ getPredictedFieldName,
+} from '../../../../common/util/analytics_utils';
export type IndexPattern = string;
-export enum ANALYSIS_CONFIG_TYPE {
- OUTLIER_DETECTION = 'outlier_detection',
- REGRESSION = 'regression',
- CLASSIFICATION = 'classification',
-}
-
export enum ANALYSIS_ADVANCED_FIELDS {
ETA = 'eta',
FEATURE_BAG_FRACTION = 'feature_bag_fraction',
@@ -156,23 +157,6 @@ export const getAnalysisType = (analysis: AnalysisConfig): string => {
return 'unknown';
};
-export const getDependentVar = (
- analysis: AnalysisConfig
-):
- | RegressionAnalysis['regression']['dependent_variable']
- | ClassificationAnalysis['classification']['dependent_variable'] => {
- let depVar = '';
-
- if (isRegressionAnalysis(analysis)) {
- depVar = analysis.regression.dependent_variable;
- }
-
- if (isClassificationAnalysis(analysis)) {
- depVar = analysis.classification.dependent_variable;
- }
- return depVar;
-};
-
export const getTrainingPercent = (
analysis: AnalysisConfig
):
@@ -190,24 +174,6 @@ export const getTrainingPercent = (
return trainingPercent;
};
-export const getPredictionFieldName = (
- analysis: AnalysisConfig
-):
- | RegressionAnalysis['regression']['prediction_field_name']
- | ClassificationAnalysis['classification']['prediction_field_name'] => {
- // If undefined will be defaulted to dependent_variable when config is created
- let predictionFieldName;
- if (isRegressionAnalysis(analysis) && analysis.regression.prediction_field_name !== undefined) {
- predictionFieldName = analysis.regression.prediction_field_name;
- } else if (
- isClassificationAnalysis(analysis) &&
- analysis.classification.prediction_field_name !== undefined
- ) {
- predictionFieldName = analysis.classification.prediction_field_name;
- }
- return predictionFieldName;
-};
-
export const getNumTopClasses = (
analysis: AnalysisConfig
): ClassificationAnalysis['classification']['num_top_classes'] => {
@@ -238,35 +204,6 @@ export const getNumTopFeatureImportanceValues = (
return numTopFeatureImportanceValues;
};
-export const getPredictedFieldName = (
- resultsField: string,
- analysis: AnalysisConfig,
- forSort?: boolean
-) => {
- // default is 'ml'
- const predictionFieldName = getPredictionFieldName(analysis);
- const defaultPredictionField = `${getDependentVar(analysis)}_prediction`;
- const predictedField = `${resultsField}.${
- predictionFieldName ? predictionFieldName : defaultPredictionField
- }`;
- return predictedField;
-};
-
-export const isOutlierAnalysis = (arg: any): arg is OutlierAnalysis => {
- const keys = Object.keys(arg);
- return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION;
-};
-
-export const isRegressionAnalysis = (arg: any): arg is RegressionAnalysis => {
- const keys = Object.keys(arg);
- return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.REGRESSION;
-};
-
-export const isClassificationAnalysis = (arg: any): arg is ClassificationAnalysis => {
- const keys = Object.keys(arg);
- return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.CLASSIFICATION;
-};
-
export const isResultsSearchBoolQuery = (arg: any): arg is ResultsSearchBoolQuery => {
if (arg === undefined) return false;
const keys = Object.keys(arg);
@@ -607,3 +544,13 @@ export const loadDocsCount = async ({
};
}
};
+
+export {
+ isOutlierAnalysis,
+ isRegressionAnalysis,
+ isClassificationAnalysis,
+ getPredictionFieldName,
+ ANALYSIS_CONFIG_TYPE,
+ getDependentVar,
+ getPredictedFieldName,
+};
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/constants.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/constants.ts
index 2f14dfdfdfca3..c2295a92af89c 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/constants.ts
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/constants.ts
@@ -3,8 +3,6 @@
* 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 DEFAULT_RESULTS_FIELD = 'ml';
export const FEATURE_IMPORTANCE = 'feature_importance';
export const FEATURE_INFLUENCE = 'feature_influence';
export const TOP_CLASSES = 'top_classes';
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts
index 847aefefbc6c8..f9c9bf26a9d16 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts
@@ -4,17 +4,16 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { getNumTopClasses, getNumTopFeatureImportanceValues } from './analytics';
+import { Field } from '../../../../common/types/fields';
import {
- getNumTopClasses,
- getNumTopFeatureImportanceValues,
getPredictedFieldName,
getDependentVar,
getPredictionFieldName,
isClassificationAnalysis,
isOutlierAnalysis,
isRegressionAnalysis,
-} from './analytics';
-import { Field } from '../../../../common/types/fields';
+} from '../../../../common/util/analytics_utils';
import { ES_FIELD_TYPES, KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public';
import { newJobCapsService } from '../../services/new_job_capabilities_service';
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx
index ccac9a697210b..2e3a5d89367ce 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx
@@ -9,7 +9,6 @@ import React, { FC } from 'react';
import { i18n } from '@kbn/i18n';
import { ExplorationPageWrapper } from '../exploration_page_wrapper';
-
import { EvaluatePanel } from './evaluate_panel';
interface Props {
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx
index 34ff36c59fa6c..84b44ef0d349f 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx
@@ -51,7 +51,6 @@ export const ExplorationPageWrapper: FC = ({ jobId, title, EvaluatePanel
/>
);
}
-
return (
<>
{isLoadingJobConfig === true && jobConfig === undefined && }
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx
index 8395a11bd6fda..eea579ef1d064 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx
@@ -28,6 +28,8 @@ import {
INDEX_STATUS,
SEARCH_SIZE,
defaultSearchQuery,
+ getAnalysisType,
+ ANALYSIS_CONFIG_TYPE,
} from '../../../../common';
import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/use_columns';
import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common';
@@ -36,6 +38,7 @@ import { ExplorationQueryBar } from '../exploration_query_bar';
import { IndexPatternPrompt } from '../index_pattern_prompt';
import { useExplorationResults } from './use_exploration_results';
+import { useMlKibana } from '../../../../../contexts/kibana';
const showingDocs = i18n.translate(
'xpack.ml.dataframe.analytics.explorationResults.documentsShownHelpText',
@@ -70,18 +73,27 @@ export const ExplorationResultsTable: FC = React.memo(
setEvaluateSearchQuery,
title,
}) => {
+ const {
+ services: {
+ mlServices: { mlApiServices },
+ },
+ } = useMlKibana();
const [searchQuery, setSearchQuery] = useState(defaultSearchQuery);
useEffect(() => {
setEvaluateSearchQuery(searchQuery);
}, [JSON.stringify(searchQuery)]);
+ const analysisType = getAnalysisType(jobConfig.analysis);
+
const classificationData = useExplorationResults(
indexPattern,
jobConfig,
searchQuery,
- getToastNotifications()
+ getToastNotifications(),
+ mlApiServices
);
+
const docFieldsCount = classificationData.columnsWithCharts.length;
const {
columnsWithCharts,
@@ -94,7 +106,6 @@ export const ExplorationResultsTable: FC = React.memo(
if (jobConfig === undefined || classificationData === undefined) {
return null;
}
-
// if it's a searchBar syntax error leave the table visible so they can try again
if (status === INDEX_STATUS.ERROR && !errorMessage.includes('failed to create query')) {
return (
@@ -184,6 +195,7 @@ export const ExplorationResultsTable: FC = React.memo(
{...classificationData}
dataTestSubj="mlExplorationDataGrid"
toastNotifications={getToastNotifications()}
+ analysisType={(analysisType as unknown) as ANALYSIS_CONFIG_TYPE}
/>