From 6e612b5622f0c19dfcddac1749b07f6558da1595 Mon Sep 17 00:00:00 2001 From: Sander Philipse <94373878+sphilipse@users.noreply.github.com> Date: Tue, 25 Apr 2023 16:03:17 +0200 Subject: [PATCH 01/13] [Enterprise Search] Create search results provider (#155641) ## Summary This adds a global search provider for Enterprise Search results, returning the web crawler and connectors. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../common/connectors/connectors.ts | 120 +++++++++++ x-pack/plugins/enterprise_search/kibana.jsonc | 1 + .../components/new_index/utils.ts | 4 +- .../search_index/connector/constants.ts | 126 ++++------- .../search_index/connector/types.ts | 10 +- .../shared/icons/connector_icons.ts | 30 +++ .../connector.svg | 0 .../crawler.svg | 0 .../{microsoft_sql.svg => mssql.svg} | 0 .../source_icons/native_connector_icons.ts | 30 --- .../source_icons/{amazon_s3.svg => s3.svg} | 0 .../enterprise_search/server/integrations.ts | 6 +- .../enterprise_search/server/plugin.ts | 29 ++- .../utils/search_result_provider.test.ts | 196 ++++++++++++++++++ .../server/utils/search_result_provider.ts | 103 +++++++++ .../plugins/enterprise_search/tsconfig.json | 1 + 16 files changed, 517 insertions(+), 139 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/common/connectors/connectors.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/icons/connector_icons.ts rename x-pack/plugins/enterprise_search/public/assets/{enterprise_search_features => source_icons}/connector.svg (100%) rename x-pack/plugins/enterprise_search/public/assets/{enterprise_search_features => source_icons}/crawler.svg (100%) rename x-pack/plugins/enterprise_search/public/assets/source_icons/{microsoft_sql.svg => mssql.svg} (100%) delete mode 100644 x-pack/plugins/enterprise_search/public/assets/source_icons/native_connector_icons.ts rename x-pack/plugins/enterprise_search/public/assets/source_icons/{amazon_s3.svg => s3.svg} (100%) create mode 100644 x-pack/plugins/enterprise_search/server/utils/search_result_provider.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/utils/search_result_provider.ts diff --git a/x-pack/plugins/enterprise_search/common/connectors/connectors.ts b/x-pack/plugins/enterprise_search/common/connectors/connectors.ts new file mode 100644 index 00000000000000..953c49b493b37a --- /dev/null +++ b/x-pack/plugins/enterprise_search/common/connectors/connectors.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export interface ConnectorServerSideDefinition { + iconPath: string; + isBeta: boolean; + isNative: boolean; + keywords: string[]; + name: string; + serviceType: string; +} + +export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ + { + iconPath: 'mongodb.svg', + isBeta: false, + isNative: true, + keywords: ['mongo', 'mongodb', 'database', 'nosql', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.mongodb.name', { + defaultMessage: 'MongoDB', + }), + serviceType: 'mongodb', + }, + { + iconPath: 'mysql.svg', + isBeta: false, + isNative: true, + keywords: ['mysql', 'sql', 'database', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.mysql.name', { + defaultMessage: 'MySQL', + }), + serviceType: 'mysql', + }, + { + iconPath: 'azure_blob_storage.svg', + isBeta: true, + isNative: false, + keywords: ['cloud', 'azure', 'blob', 's3', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.azureBlob.name', { + defaultMessage: 'Azure Blob Storage', + }), + serviceType: 'azure_blob_storage', + }, + { + iconPath: 'google_cloud_storage.svg', + isBeta: true, + isNative: false, + keywords: ['google', 'cloud', 'blob', 's3', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.googleCloud.name', { + defaultMessage: 'Google Cloud Storage', + }), + serviceType: 'google_cloud_storage', + }, + { + iconPath: 'mssql.svg', + isBeta: true, + isNative: false, + keywords: ['mssql', 'microsoft', 'sql', 'database', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.microsoftSQL.name', { + defaultMessage: 'Microsoft SQL', + }), + serviceType: 'mssql', + }, + { + iconPath: 'network_drive.svg', + isBeta: true, + isNative: false, + keywords: ['network', 'drive', 'file', 'directory', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.networkDrive.name', { + defaultMessage: 'Network drive', + }), + serviceType: 'network_drive', + }, + { + iconPath: 'oracle.svg', + isBeta: true, + isNative: false, + keywords: ['oracle', 'sql', 'database', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.oracle.name', { + defaultMessage: 'Oracle', + }), + serviceType: 'oracle', + }, + { + iconPath: 'postgresql.svg', + isBeta: true, + isNative: false, + keywords: ['postgresql', 'sql', 'database', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.postgresql.name', { + defaultMessage: 'Postgresql', + }), + serviceType: 'postgresql', + }, + { + iconPath: 's3.svg', + isBeta: true, + isNative: false, + keywords: ['s3', 'cloud', 'amazon', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.s3.name', { + defaultMessage: 'S3', + }), + serviceType: 's3', + }, + { + iconPath: 'custom.svg', + isBeta: true, + isNative: false, + keywords: ['custom', 'connector', 'code'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.customConnector.name', { + defaultMessage: 'Customized connector', + }), + serviceType: '', + }, +]; diff --git a/x-pack/plugins/enterprise_search/kibana.jsonc b/x-pack/plugins/enterprise_search/kibana.jsonc index 5886cf02bfd47e..5593addaeca352 100644 --- a/x-pack/plugins/enterprise_search/kibana.jsonc +++ b/x-pack/plugins/enterprise_search/kibana.jsonc @@ -26,6 +26,7 @@ ], "optionalPlugins": [ "customIntegrations", + "globalSearch", "home", "ml", "spaces", diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/utils.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/utils.ts index a1d182434f3f1a..e4bdceb5dcbdf0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/utils.ts @@ -7,9 +7,9 @@ import { INGESTION_METHOD_IDS } from '../../../../../common/constants'; -import connectorLogo from '../../../../assets/enterprise_search_features/connector.svg'; +import connectorLogo from '../../../../assets/source_icons/connector.svg'; -import crawlerLogo from '../../../../assets/enterprise_search_features/crawler.svg'; +import crawlerLogo from '../../../../assets/source_icons/crawler.svg'; import { UNIVERSAL_LANGUAGE_VALUE } from './constants'; import { LanguageForOptimization } from './types'; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/constants.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/constants.ts index 9aaea14fde6dbb..b6269bb91aa87b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/constants.ts @@ -5,137 +5,83 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; - -import { CONNECTOR_ICONS } from '../../../../../assets/source_icons/native_connector_icons'; +import { CONNECTOR_DEFINITIONS } from '../../../../../../common/connectors/connectors'; import { docLinks } from '../../../../shared/doc_links'; +import { CONNECTOR_ICONS } from '../../../../shared/icons/connector_icons'; -import { ConnectorDefinition } from './types'; +import { ConnectorClientSideDefinition } from './types'; -export const CONNECTORS: ConnectorDefinition[] = [ - { - docsUrl: docLinks.connectorsMongoDB, - externalAuthDocsUrl: 'https://www.mongodb.com/docs/atlas/app-services/authentication/', - externalDocsUrl: 'https://www.mongodb.com/docs/', - icon: CONNECTOR_ICONS.mongodb, - isBeta: false, - isNative: true, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.mongodb.name', { - defaultMessage: 'MongoDB', - }), - serviceType: 'mongodb', - }, - { - docsUrl: docLinks.connectorsMySQL, - externalDocsUrl: 'https://dev.mysql.com/doc/', - icon: CONNECTOR_ICONS.mysql, - isBeta: false, - isNative: true, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.mysql.name', { - defaultMessage: 'MySQL', - }), - serviceType: 'mysql', - }, - { +export const CONNECTORS_DICT: Record = { + azure_blob_storage: { docsUrl: docLinks.connectorsAzureBlobStorage, externalAuthDocsUrl: 'https://learn.microsoft.com/azure/storage/common/authorize-data-access', externalDocsUrl: 'https://learn.microsoft.com/azure/storage/blobs/', icon: CONNECTOR_ICONS.azure_blob_storage, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.azureBlob.name', { - defaultMessage: 'Azure Blob Storage', - }), - serviceType: 'azure_blob_storage', }, - { + custom: { + docsUrl: docLinks.connectors, + externalAuthDocsUrl: '', + externalDocsUrl: '', + icon: CONNECTOR_ICONS.custom, + }, + google_cloud_storage: { docsUrl: docLinks.connectorsGoogleCloudStorage, externalAuthDocsUrl: 'https://cloud.google.com/storage/docs/authentication', externalDocsUrl: 'https://cloud.google.com/storage/docs', icon: CONNECTOR_ICONS.google_cloud_storage, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.googleCloud.name', { - defaultMessage: 'Google Cloud Storage', - }), - serviceType: 'google_cloud_storage', }, - { + mongodb: { + docsUrl: docLinks.connectorsMongoDB, + externalAuthDocsUrl: 'https://www.mongodb.com/docs/atlas/app-services/authentication/', + externalDocsUrl: 'https://www.mongodb.com/docs/', + icon: CONNECTOR_ICONS.mongodb, + }, + mssql: { docsUrl: docLinks.connectorsMicrosoftSQL, externalAuthDocsUrl: 'https://learn.microsoft.com/sql/relational-databases/security/authentication-access/getting-started-with-database-engine-permissions', externalDocsUrl: 'https://learn.microsoft.com/sql/', icon: CONNECTOR_ICONS.microsoft_sql, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.microsoftSQL.name', { - defaultMessage: 'Microsoft SQL', - }), - serviceType: 'mssql', }, - { + mysql: { + docsUrl: docLinks.connectorsMySQL, + externalDocsUrl: 'https://dev.mysql.com/doc/', + icon: CONNECTOR_ICONS.mysql, + }, + network_drive: { docsUrl: docLinks.connectorsNetworkDrive, externalAuthDocsUrl: '', externalDocsUrl: '', icon: CONNECTOR_ICONS.network_drive, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.networkDrive.name', { - defaultMessage: 'Network drive', - }), - serviceType: 'network_drive', }, - { + oracle: { docsUrl: docLinks.connectorsOracle, externalAuthDocsUrl: 'https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/index.html', externalDocsUrl: 'https://docs.oracle.com/database/oracle/oracle-database/', icon: CONNECTOR_ICONS.oracle, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.oracle.name', { - defaultMessage: 'Oracle', - }), - serviceType: 'oracle', }, - { + postgresql: { docsUrl: docLinks.connectorsPostgreSQL, externalAuthDocsUrl: 'https://www.postgresql.org/docs/15/auth-methods.html', externalDocsUrl: 'https://www.postgresql.org/docs/', icon: CONNECTOR_ICONS.postgresql, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.postgresql.name', { - defaultMessage: 'Postgresql', - }), - serviceType: 'postgresql', }, - { + s3: { docsUrl: docLinks.connectorsS3, externalAuthDocsUrl: 'https://docs.aws.amazon.com/s3/index.html', externalDocsUrl: '', icon: CONNECTOR_ICONS.amazon_s3, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.s3.name', { - defaultMessage: 'S3', - }), - serviceType: 's3', }, - { - docsUrl: docLinks.connectors, - externalAuthDocsUrl: '', - externalDocsUrl: '', - icon: CONNECTOR_ICONS.custom, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.customConnector.name', { - defaultMessage: 'Custom connector', - }), - serviceType: '', - }, -]; +}; + +export const CONNECTORS = CONNECTOR_DEFINITIONS.map((connector) => ({ + ...connector, + ...(connector.serviceType && CONNECTORS_DICT[connector.serviceType] + ? CONNECTORS_DICT[connector.serviceType] + : CONNECTORS_DICT.custom), +})); export const CUSTOM_CONNECTORS = CONNECTORS.filter(({ isNative }) => !isNative); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/types.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/types.ts index 0b061d1aa32412..68d990650a1751 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/types.ts @@ -5,13 +5,13 @@ * 2.0. */ -export interface ConnectorDefinition { +import { ConnectorServerSideDefinition } from '../../../../../../common/connectors/connectors'; + +export interface ConnectorClientSideDefinition { docsUrl?: string; externalAuthDocsUrl?: string; externalDocsUrl: string; icon: string; - isBeta: boolean; - isNative: boolean; - name: string; - serviceType: string; } + +export type ConnectorDefinition = ConnectorClientSideDefinition & ConnectorServerSideDefinition; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/icons/connector_icons.ts b/x-pack/plugins/enterprise_search/public/applications/shared/icons/connector_icons.ts new file mode 100644 index 00000000000000..f9d9eb5df97993 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/icons/connector_icons.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import azure_blob_storage from '../../../assets/source_icons/azure_blob_storage.svg'; +import custom from '../../../assets/source_icons/custom.svg'; +import google_cloud_storage from '../../../assets/source_icons/google_cloud_storage.svg'; +import mongodb from '../../../assets/source_icons/mongodb.svg'; +import microsoft_sql from '../../../assets/source_icons/mssql.svg'; +import mysql from '../../../assets/source_icons/mysql.svg'; +import network_drive from '../../../assets/source_icons/network_drive.svg'; +import oracle from '../../../assets/source_icons/oracle.svg'; +import postgresql from '../../../assets/source_icons/postgresql.svg'; +import amazon_s3 from '../../../assets/source_icons/s3.svg'; + +export const CONNECTOR_ICONS = { + amazon_s3, + azure_blob_storage, + custom, + google_cloud_storage, + microsoft_sql, + mongodb, + mysql, + network_drive, + oracle, + postgresql, +}; diff --git a/x-pack/plugins/enterprise_search/public/assets/enterprise_search_features/connector.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/connector.svg similarity index 100% rename from x-pack/plugins/enterprise_search/public/assets/enterprise_search_features/connector.svg rename to x-pack/plugins/enterprise_search/public/assets/source_icons/connector.svg diff --git a/x-pack/plugins/enterprise_search/public/assets/enterprise_search_features/crawler.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/crawler.svg similarity index 100% rename from x-pack/plugins/enterprise_search/public/assets/enterprise_search_features/crawler.svg rename to x-pack/plugins/enterprise_search/public/assets/source_icons/crawler.svg diff --git a/x-pack/plugins/enterprise_search/public/assets/source_icons/microsoft_sql.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/mssql.svg similarity index 100% rename from x-pack/plugins/enterprise_search/public/assets/source_icons/microsoft_sql.svg rename to x-pack/plugins/enterprise_search/public/assets/source_icons/mssql.svg diff --git a/x-pack/plugins/enterprise_search/public/assets/source_icons/native_connector_icons.ts b/x-pack/plugins/enterprise_search/public/assets/source_icons/native_connector_icons.ts deleted file mode 100644 index 49767bb497f8b7..00000000000000 --- a/x-pack/plugins/enterprise_search/public/assets/source_icons/native_connector_icons.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import amazon_s3 from './amazon_s3.svg'; -import azure_blob_storage from './azure_blob_storage.svg'; -import custom from './custom.svg'; -import google_cloud_storage from './google_cloud_storage.svg'; -import microsoft_sql from './microsoft_sql.svg'; -import mongodb from './mongodb.svg'; -import mysql from './mysql.svg'; -import network_drive from './network_drive.svg'; -import oracle from './oracle.svg'; -import postgresql from './postgresql.svg'; - -export const CONNECTOR_ICONS = { - amazon_s3, - azure_blob_storage, - custom, - google_cloud_storage, - microsoft_sql, - mongodb, - mysql, - network_drive, - oracle, - postgresql, -}; diff --git a/x-pack/plugins/enterprise_search/public/assets/source_icons/amazon_s3.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/s3.svg similarity index 100% rename from x-pack/plugins/enterprise_search/public/assets/source_icons/amazon_s3.svg rename to x-pack/plugins/enterprise_search/public/assets/source_icons/s3.svg diff --git a/x-pack/plugins/enterprise_search/server/integrations.ts b/x-pack/plugins/enterprise_search/server/integrations.ts index ae5eb87aad7335..8d68f1c3e1c580 100644 --- a/x-pack/plugins/enterprise_search/server/integrations.ts +++ b/x-pack/plugins/enterprise_search/server/integrations.ts @@ -588,9 +588,7 @@ export const registerEnterpriseSearchIntegrations = ( icons: [ { type: 'svg', - src: http.basePath.prepend( - '/plugins/enterpriseSearch/assets/source_icons/microsoft_sql.svg' - ), + src: http.basePath.prepend('/plugins/enterpriseSearch/assets/source_icons/mssql.svg'), }, ], shipper: 'enterprise_search', @@ -651,7 +649,7 @@ export const registerEnterpriseSearchIntegrations = ( icons: [ { type: 'svg', - src: http.basePath.prepend('/plugins/enterpriseSearch/assets/source_icons/amazon_s3.svg'), + src: http.basePath.prepend('/plugins/enterpriseSearch/assets/source_icons/s3.svg'), }, ], shipper: 'enterprise_search', diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 729258fe4e97c5..77dc6d4c839f52 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -18,6 +18,7 @@ import { import { CustomIntegrationsPluginSetup } from '@kbn/custom-integrations-plugin/server'; import { DataPluginStart } from '@kbn/data-plugin/server/plugin'; import { PluginSetupContract as FeaturesPluginSetup } from '@kbn/features-plugin/server'; +import { GlobalSearchPluginSetup } from '@kbn/global-search-plugin/server'; import type { GuidedOnboardingPluginSetup } from '@kbn/guided-onboarding-plugin/server'; import { InfraPluginSetup } from '@kbn/infra-plugin/server'; import type { MlPluginSetup } from '@kbn/ml-plugin/server'; @@ -76,31 +77,34 @@ import { workplaceSearchTelemetryType } from './saved_objects/workplace_search/t import { uiSettings as enterpriseSearchUISettings } from './ui_settings'; +import { getSearchResultProvider } from './utils/search_result_provider'; + import { ConfigType } from '.'; interface PluginsSetup { - usageCollection?: UsageCollectionSetup; - security: SecurityPluginSetup; + customIntegrations?: CustomIntegrationsPluginSetup; features: FeaturesPluginSetup; + globalSearch: GlobalSearchPluginSetup; + guidedOnboarding: GuidedOnboardingPluginSetup; infra: InfraPluginSetup; - customIntegrations?: CustomIntegrationsPluginSetup; ml?: MlPluginSetup; - guidedOnboarding: GuidedOnboardingPluginSetup; + security: SecurityPluginSetup; + usageCollection?: UsageCollectionSetup; } interface PluginsStart { - spaces?: SpacesPluginStart; - security: SecurityPluginStart; data: DataPluginStart; + security: SecurityPluginStart; + spaces?: SpacesPluginStart; } export interface RouteDependencies { - router: IRouter; config: ConfigType; - log: Logger; enterpriseSearchRequestHandler: IEnterpriseSearchRequestHandler; getSavedObjectsService?(): SavedObjectsServiceStart; + log: Logger; ml?: MlPluginSetup; + router: IRouter; } export class EnterpriseSearchPlugin implements Plugin { @@ -118,6 +122,7 @@ export class EnterpriseSearchPlugin implements Plugin { usageCollection, security, features, + globalSearch, infra, customIntegrations, ml, @@ -284,6 +289,14 @@ export class EnterpriseSearchPlugin implements Plugin { if (config.hasNativeConnectors) { guidedOnboarding.registerGuideConfig(databaseSearchGuideId, databaseSearchGuideConfig); } + + /** + * Register our integrations in the global search bar + */ + + if (globalSearch) { + globalSearch.registerResultProvider(getSearchResultProvider(http.basePath, config)); + } } public start() {} diff --git a/x-pack/plugins/enterprise_search/server/utils/search_result_provider.test.ts b/x-pack/plugins/enterprise_search/server/utils/search_result_provider.test.ts new file mode 100644 index 00000000000000..d5ecee88f80cfc --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/utils/search_result_provider.test.ts @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { NEVER } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { ENTERPRISE_SEARCH_CONTENT_PLUGIN } from '../../common/constants'; + +import { getSearchResultProvider } from './search_result_provider'; + +const getTestScheduler = () => { + return new TestScheduler((actual, expected) => { + return expect(actual).toEqual(expected); + }); +}; + +describe('Enterprise Search search provider', () => { + const basePathMock = { + prepend: (input: string) => `/kbn${input}`, + } as any; + + const crawlerResult = { + icon: '/kbn/plugins/enterpriseSearch/assets/source_icons/crawler.svg', + id: 'elastic-crawler', + score: 75, + title: 'Elastic Web Crawler', + type: 'Enterprise Search', + url: { + path: `${ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL}/search_indices/new_index/crawler`, + prependBasePath: true, + }, + }; + + const mongoResult = { + icon: '/kbn/plugins/enterpriseSearch/assets/source_icons/mongodb.svg', + id: 'mongodb', + score: 75, + title: 'MongoDB', + type: 'Enterprise Search', + url: { + path: `${ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL}/search_indices/new_index/connector?service_type=mongodb`, + prependBasePath: true, + }, + }; + + const searchResultProvider = getSearchResultProvider(basePathMock, { + hasConnectors: true, + hasWebCrawler: true, + } as any); + + beforeEach(() => {}); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('find', () => { + it('returns formatted results', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: 'crawler' }, + { aborted$: NEVER, maxResults: 100, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [crawlerResult], + }); + }); + }); + + it('returns everything on empty string', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: '' }, + { aborted$: NEVER, maxResults: 100, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: expect.arrayContaining([ + { ...crawlerResult, score: 80 }, + { ...mongoResult, score: 80 }, + ]), + }); + }); + }); + + it('respect maximum results', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: '' }, + { aborted$: NEVER, maxResults: 1, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [{ ...crawlerResult, score: 80 }], + }); + }); + }); + + it('omits crawler if config has crawler disabled', () => { + const searchProvider = getSearchResultProvider(basePathMock, { + hasConnectors: true, + hasWebCrawler: false, + } as any); + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchProvider.find( + { term: '' }, + { aborted$: NEVER, maxResults: 100, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: expect.not.arrayContaining([{ ...crawlerResult, score: 80 }]), + }); + }); + }); + + it('omits connectors if config has connectors disabled', () => { + const searchProvider = getSearchResultProvider(basePathMock, { + hasConnectors: false, + hasWebCrawler: true, + } as any); + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchProvider.find( + { term: '' }, + { aborted$: NEVER, maxResults: 100, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: expect.not.arrayContaining([{ mongoResult, score: 80 }]), + }); + }); + }); + + it('returns nothing if tag is specified', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { tags: ['tag'], term: '' }, + { aborted$: NEVER, maxResults: 1, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [], + }); + }); + }); + it('returns nothing if unknown type is specified', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: '', types: ['tag'] }, + { aborted$: NEVER, maxResults: 1, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [], + }); + }); + }); + it('returns results for integrations tag', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: 'crawler', types: ['integration'] }, + { aborted$: NEVER, maxResults: 1, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [crawlerResult], + }); + }); + }); + it('returns results for enterprise search tag', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: 'crawler', types: ['enterprise search'] }, + { aborted$: NEVER, maxResults: 1, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [crawlerResult], + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/utils/search_result_provider.ts b/x-pack/plugins/enterprise_search/server/utils/search_result_provider.ts new file mode 100644 index 00000000000000..29f791dfd5ca11 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/utils/search_result_provider.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { from, takeUntil } from 'rxjs'; + +import { IBasePath } from '@kbn/core-http-server'; +import { GlobalSearchResultProvider } from '@kbn/global-search-plugin/server'; + +import { ConfigType } from '..'; +import { CONNECTOR_DEFINITIONS } from '../../common/connectors/connectors'; +import { + ENTERPRISE_SEARCH_CONNECTOR_CRAWLER_SERVICE_TYPE, + ENTERPRISE_SEARCH_CONTENT_PLUGIN, +} from '../../common/constants'; + +export function toSearchResult({ + basePath, + iconPath, + name, + score, + serviceType, +}: { + basePath: IBasePath; + iconPath: string; + name: string; + score: number; + serviceType: string; +}) { + return { + icon: iconPath + ? basePath.prepend(`/plugins/enterpriseSearch/assets/source_icons/${iconPath}`) + : 'logoEnterpriseSearch', + id: serviceType, + score, + title: name, + type: 'Enterprise Search', + url: { + path: `${ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL}/search_indices/new_index/${ + serviceType === ENTERPRISE_SEARCH_CONNECTOR_CRAWLER_SERVICE_TYPE + ? 'crawler' + : `connector?service_type=${serviceType}` + }`, + prependBasePath: true, + }, + }; +} + +export function getSearchResultProvider( + basePath: IBasePath, + config: ConfigType +): GlobalSearchResultProvider { + return { + find: ({ term, types, tags }, { aborted$, maxResults }) => { + if ( + tags || + (types && !(types.includes('integration') || types.includes('enterprise search'))) + ) { + return from([[]]); + } + const result = [ + ...(config.hasWebCrawler + ? [ + { + iconPath: 'crawler.svg', + keywords: ['crawler', 'web', 'website', 'internet', 'google'], + name: 'Elastic Web Crawler', + serviceType: ENTERPRISE_SEARCH_CONNECTOR_CRAWLER_SERVICE_TYPE, + }, + ] + : []), + ...(config.hasConnectors ? CONNECTOR_DEFINITIONS : []), + ] + .map(({ iconPath, keywords, name, serviceType }) => { + let score = 0; + const searchTerm = (term || '').toLowerCase(); + const searchName = name.toLowerCase(); + if (!searchTerm) { + score = 80; + } else if (searchName === searchTerm) { + score = 100; + } else if (searchName.startsWith(searchTerm)) { + score = 90; + } else if (searchName.includes(searchTerm)) { + score = 75; + } else if (serviceType === searchTerm) { + score = 65; + } else if (keywords.some((keyword) => keyword.includes(searchTerm))) { + score = 50; + } + return toSearchResult({ basePath, iconPath, name, score, serviceType }); + }) + .filter(({ score }) => score > 0) + .slice(0, maxResults); + return from([result]).pipe(takeUntil(aborted$)); + }, + getSearchableTypes: () => ['enterprise search', 'integration'], + id: 'enterpriseSearch', + }; +} diff --git a/x-pack/plugins/enterprise_search/tsconfig.json b/x-pack/plugins/enterprise_search/tsconfig.json index 4ba8f2b48d8145..3bd55202cfe524 100644 --- a/x-pack/plugins/enterprise_search/tsconfig.json +++ b/x-pack/plugins/enterprise_search/tsconfig.json @@ -59,5 +59,6 @@ "@kbn/field-types", "@kbn/core-elasticsearch-server-mocks", "@kbn/shared-ux-link-redirect-app", + "@kbn/global-search-plugin", ] } From 84a8957d6ea136fc018dd082176f71033e876c94 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Tue, 25 Apr 2023 16:08:17 +0200 Subject: [PATCH 02/13] [Cases] Description markdown redesign (#155151) ## Summary This PR redesigns the description markdown editor on the case view page. https://user-images.githubusercontent.com/117571355/233022102-d9540765-7961-4bd7-9758-bf32194ca6b0.mov ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../case_view/case_view_page.test.tsx | 17 +- .../components/case_view_activity.test.tsx | 27 +- .../components/case_view_activity.tsx | 7 +- .../components/case_view/translations.ts | 8 + .../description/description_wrapper.test.tsx | 88 ------ .../description/description_wrapper.tsx | 106 ------- .../components/description/index.test.tsx | 155 ++++++++++ .../public/components/description/index.tsx | 203 +++++++++++++ .../public/components/description/schema.ts | 26 ++ .../editable_markdown_footer.tsx} | 12 +- .../editable_markdown_renderer.test.tsx | 220 ++++++++++++++ .../editable_markdown_renderer.tsx | 105 +++++++ .../components/markdown_editor/index.tsx | 2 + .../scrollable_markdown_renderer.test.tsx | 29 ++ .../scrollable_markdown_renderer.tsx | 31 ++ .../user_actions/comment/actions.tsx | 7 +- .../user_actions/description.test.tsx | 46 +-- .../components/user_actions/description.tsx | 100 +------ .../components/user_actions/index.test.tsx | 12 +- .../user_actions/markdown_form.test.tsx | 274 ++---------------- .../components/user_actions/markdown_form.tsx | 91 +----- .../cypress/e2e/cases/creation.cy.ts | 4 +- .../cypress/screens/case_details.ts | 11 +- .../services/cases/single_case_view.ts | 2 +- .../apps/cases/group1/create_case_form.ts | 2 +- .../apps/cases/group1/view_case.ts | 16 +- .../apps/cases/group2/upgrade.ts | 10 +- 27 files changed, 892 insertions(+), 719 deletions(-) delete mode 100644 x-pack/plugins/cases/public/components/description/description_wrapper.test.tsx delete mode 100644 x-pack/plugins/cases/public/components/description/description_wrapper.tsx create mode 100644 x-pack/plugins/cases/public/components/description/index.test.tsx create mode 100644 x-pack/plugins/cases/public/components/description/index.tsx create mode 100644 x-pack/plugins/cases/public/components/description/schema.ts rename x-pack/plugins/cases/public/components/{user_actions/markdown_form_footer.tsx => markdown_editor/editable_markdown_footer.tsx} (76%) create mode 100644 x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx create mode 100644 x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.tsx create mode 100644 x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.test.tsx create mode 100644 x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.tsx diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx index f247945c7c7003..d33cf6efd4fbff 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx @@ -189,7 +189,7 @@ describe('CaseViewPage', () => { expect(result.getAllByText(data.createdBy.fullName!)[0]).toBeInTheDocument(); expect( - within(result.getByTestId('description-action')).getByTestId('user-action-markdown') + within(result.getByTestId('description')).getByTestId('scrollable-markdown') ).toHaveTextContent(data.description); expect(result.getByTestId('case-view-status-action-button')).toHaveTextContent( @@ -604,15 +604,15 @@ describe('CaseViewPage', () => { }); describe('description', () => { - it('renders the descriptions user correctly', async () => { + it('renders the description correctly', async () => { appMockRenderer.render(); - const description = within(screen.getByTestId('description-action')); + const description = within(screen.getByTestId('description')); - expect(await description.findByText('Leslie Knope')).toBeInTheDocument(); + expect(await description.findByText(caseData.description)).toBeInTheDocument(); }); - it('should display description isLoading', async () => { + it('should display description when case is loading', async () => { useUpdateCaseMock.mockImplementation(() => ({ ...defaultUpdateCaseState, isLoading: true, @@ -622,8 +622,7 @@ describe('CaseViewPage', () => { appMockRenderer.render(); await waitFor(() => { - expect(screen.getByTestId('description-loading')).toBeInTheDocument(); - expect(screen.queryByTestId('description-action')).not.toBeInTheDocument(); + expect(screen.getByTestId('description')).toBeInTheDocument(); }); }); @@ -636,11 +635,11 @@ describe('CaseViewPage', () => { userEvent.type(await screen.findByTestId('euiMarkdownEditorTextArea'), newComment); - userEvent.click(await screen.findByTestId('editable-description-edit-icon')); + userEvent.click(await screen.findByTestId('description-edit-icon')); userEvent.type(screen.getAllByTestId('euiMarkdownEditorTextArea')[0], 'Edited!'); - userEvent.click(screen.getByTestId('user-action-save-markdown')); + userEvent.click(screen.getByTestId('editable-save-markdown')); expect(await screen.findByTestId('euiMarkdownEditorTextArea')).toHaveTextContent( newComment diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx index aa28340c55308c..7386d8bcd7ea10 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx @@ -493,17 +493,38 @@ describe.skip('Case View Page activity tab', () => { }); describe('User actions', () => { - it('renders the descriptions user correctly', async () => { + it('renders the description correctly', async () => { appMockRender = createAppMockRenderer(); const result = appMockRender.render(); - const description = within(result.getByTestId('description-action')); + const description = within(result.getByTestId('description')); await waitFor(() => { - expect(description.getByText('Leslie Knope')).toBeInTheDocument(); + expect(description.getByText(caseData.description)).toBeInTheDocument(); }); }); + it('renders edit description user action correctly', async () => { + useFindCaseUserActionsMock.mockReturnValue({ + ...defaultUseFindCaseUserActions, + data: { + userActions: [ + getUserAction('description', 'create'), + getUserAction('description', 'update'), + ], + }, + }); + + appMockRender = createAppMockRenderer(); + const result = appMockRender.render(); + + const userActions = within(result.getAllByTestId('user-actions-list')[1]); + + expect( + userActions.getByTestId('description-update-action-description-update') + ).toBeInTheDocument(); + }); + it('renders the unassigned users correctly', async () => { useFindCaseUserActionsMock.mockReturnValue({ ...defaultUseFindCaseUserActions, diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx index b178fe37bb4506..c4f387c6174173 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx @@ -37,7 +37,7 @@ import { convertToCaseUserWithProfileInfo } from '../../user_profiles/user_conve import type { UserActivityParams } from '../../user_actions_activity_bar/types'; import { CASE_VIEW_PAGE_TABS } from '../../../../common/types'; import { CaseViewTabs } from '../case_view_tabs'; -import { DescriptionWrapper } from '../../description/description_wrapper'; +import { Description } from '../../description'; const buildUserProfilesMap = (users?: CaseUsers): Map => { const userProfiles = new Map(); @@ -210,10 +210,9 @@ export const CaseViewActivity = ({ <> - diff --git a/x-pack/plugins/cases/public/components/case_view/translations.ts b/x-pack/plugins/cases/public/components/case_view/translations.ts index 8fc80c1a0aba39..d0be88f729a691 100644 --- a/x-pack/plugins/cases/public/components/case_view/translations.ts +++ b/x-pack/plugins/cases/public/components/case_view/translations.ts @@ -76,6 +76,14 @@ export const EDIT_DESCRIPTION = i18n.translate('xpack.cases.caseView.edit.descri defaultMessage: 'Edit description', }); +export const COLLAPSE_DESCRIPTION = i18n.translate('xpack.cases.caseView.description.collapse', { + defaultMessage: 'Collapse description', +}); + +export const EXPAND_DESCRIPTION = i18n.translate('xpack.cases.caseView.description.expand', { + defaultMessage: 'Expand description', +}); + export const QUOTE = i18n.translate('xpack.cases.caseView.edit.quote', { defaultMessage: 'Quote', }); diff --git a/x-pack/plugins/cases/public/components/description/description_wrapper.test.tsx b/x-pack/plugins/cases/public/components/description/description_wrapper.test.tsx deleted file mode 100644 index 40fcf91ffe1fa7..00000000000000 --- a/x-pack/plugins/cases/public/components/description/description_wrapper.test.tsx +++ /dev/null @@ -1,88 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { waitFor, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -// eslint-disable-next-line @kbn/eslint/module_migration -import routeData from 'react-router'; - -import { useUpdateComment } from '../../containers/use_update_comment'; -import { basicCase } from '../../containers/mock'; -import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer } from '../../common/mock'; -import { DescriptionWrapper } from './description_wrapper'; -import { waitForComponentToUpdate } from '../../common/test_utils'; - -const onUpdateField = jest.fn(); - -const defaultProps = { - data: basicCase, - onUpdateField, - isLoadingDescription: false, - userProfiles: new Map(), -}; - -jest.mock('../../containers/use_update_comment'); -jest.mock('../../common/lib/kibana'); - -const useUpdateCommentMock = useUpdateComment as jest.Mock; -const patchComment = jest.fn(); - -// FLAKY: -describe.skip(`DescriptionWrapper`, () => { - const sampleData = { - content: 'what a great comment update', - }; - let appMockRender: AppMockRenderer; - - beforeEach(() => { - jest.clearAllMocks(); - useUpdateCommentMock.mockReturnValue({ - isLoadingIds: [], - patchComment, - }); - - jest.spyOn(routeData, 'useParams').mockReturnValue({ detailName: 'case-id' }); - appMockRender = createAppMockRenderer(); - }); - - it('renders correctly', () => { - appMockRender.render(); - - expect(screen.getByTestId('description-action')).toBeInTheDocument(); - }); - - it('renders loading state', () => { - appMockRender.render(); - - expect(screen.getByTestId('description-loading')).toBeInTheDocument(); - }); - - it('calls update description when description markdown is saved', async () => { - const newData = { - content: 'what a great comment update', - }; - - appMockRender.render(); - - userEvent.click(screen.getByTestId('editable-description-edit-icon')); - - userEvent.clear(screen.getAllByTestId('euiMarkdownEditorTextArea')[0]); - - userEvent.type(screen.getAllByTestId('euiMarkdownEditorTextArea')[0], newData.content); - - userEvent.click(screen.getByTestId('user-action-save-markdown')); - - await waitForComponentToUpdate(); - - await waitFor(() => { - expect(screen.queryByTestId('user-action-markdown-form')).not.toBeInTheDocument(); - expect(onUpdateField).toBeCalledWith({ key: 'description', value: sampleData.content }); - }); - }); -}); diff --git a/x-pack/plugins/cases/public/components/description/description_wrapper.tsx b/x-pack/plugins/cases/public/components/description/description_wrapper.tsx deleted file mode 100644 index 77b29abfa0c0d5..00000000000000 --- a/x-pack/plugins/cases/public/components/description/description_wrapper.tsx +++ /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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { EuiCommentProps } from '@elastic/eui'; -import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; -import { EuiCommentList, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; - -import React, { useMemo } from 'react'; -import styled from 'styled-components'; - -import type { Case } from '../../containers/types'; -import type { OnUpdateFields } from '../case_view/types'; -import { getDescriptionUserAction } from '../user_actions/description'; -import { useUserActionsHandler } from '../user_actions/use_user_actions_handler'; -import { useCasesContext } from '../cases_context/use_cases_context'; - -interface DescriptionWrapperProps { - data: Case; - isLoadingDescription: boolean; - userProfiles: Map; - onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void; -} - -const MyEuiCommentList = styled(EuiCommentList)` - & .euiComment > [class*='euiTimelineItemIcon-top'] { - display: none; - } - - & .draftFooter { - & .euiCommentEvent__body { - padding: 0; - } - } - - & .euiComment.isEdit { - & .euiCommentEvent { - border: none; - box-shadow: none; - } - - & .euiCommentEvent__body { - padding: 0; - } - - & .euiCommentEvent__header { - display: none; - } - } -`; - -export const DescriptionWrapper = React.memo( - ({ - data: caseData, - isLoadingDescription, - userProfiles, - onUpdateField, - }: DescriptionWrapperProps) => { - const { appId } = useCasesContext(); - - const { commentRefs, manageMarkdownEditIds, handleManageMarkdownEditId } = - useUserActionsHandler(); - - const descriptionCommentListObj: EuiCommentProps = useMemo( - () => - getDescriptionUserAction({ - appId, - caseData, - commentRefs, - userProfiles, - manageMarkdownEditIds, - isLoadingDescription, - onUpdateField, - handleManageMarkdownEditId, - }), - [ - appId, - caseData, - commentRefs, - manageMarkdownEditIds, - isLoadingDescription, - userProfiles, - onUpdateField, - handleManageMarkdownEditId, - ] - ); - - return isLoadingDescription ? ( - - - - - - ) : ( - - ); - } -); - -DescriptionWrapper.displayName = 'DescriptionWrapper'; diff --git a/x-pack/plugins/cases/public/components/description/index.test.tsx b/x-pack/plugins/cases/public/components/description/index.test.tsx new file mode 100644 index 00000000000000..bf92733b4526ae --- /dev/null +++ b/x-pack/plugins/cases/public/components/description/index.test.tsx @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { basicCase } from '../../containers/mock'; + +import { Description } from '.'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer, noUpdateCasesPermissions, TestProviders } from '../../common/mock'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); + +const defaultProps = { + appId: 'testAppId', + caseData: { + ...basicCase, + }, + isLoadingDescription: false, +}; + +describe('Description', () => { + const onUpdateField = jest.fn(); + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders description correctly', async () => { + appMockRender.render(); + + expect(screen.getByTestId('description')).toBeInTheDocument(); + expect(screen.getByText('Security banana Issue')).toBeInTheDocument(); + }); + + it('hides and shows the description correctly when collapse button clicked', async () => { + const res = appMockRender.render( + + ); + + userEvent.click(res.getByTestId('description-collapse-icon')); + + await waitFor(() => { + expect(screen.queryByText('Security banana Issue')).not.toBeInTheDocument(); + }); + + userEvent.click(res.getByTestId('description-collapse-icon')); + + await waitFor(() => { + expect(screen.getByText('Security banana Issue')).toBeInTheDocument(); + }); + }); + + it('shows textarea on edit click', async () => { + const res = appMockRender.render( + + ); + + userEvent.click(res.getByTestId('description-edit-icon')); + + await waitFor(() => { + expect(screen.getByTestId('euiMarkdownEditorTextArea')).toBeInTheDocument(); + }); + }); + + it('edits the description correctly when saved', async () => { + const editedDescription = 'New updated description'; + const res = appMockRender.render( + + ); + + userEvent.click(res.getByTestId('description-edit-icon')); + + userEvent.clear(screen.getByTestId('euiMarkdownEditorTextArea')); + userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), editedDescription); + + userEvent.click(screen.getByTestId('editable-save-markdown')); + + await waitFor(() => { + expect(onUpdateField).toHaveBeenCalledWith({ key: 'description', value: editedDescription }); + }); + }); + + it('keeps the old description correctly when canceled', async () => { + const editedDescription = 'New updated description'; + const res = appMockRender.render( + + ); + + userEvent.click(res.getByTestId('description-edit-icon')); + + userEvent.clear(screen.getByTestId('euiMarkdownEditorTextArea')); + userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), editedDescription); + + userEvent.click(screen.getByTestId('editable-cancel-markdown')); + + await waitFor(() => { + expect(onUpdateField).not.toHaveBeenCalled(); + expect(screen.getByText('Security banana Issue')).toBeInTheDocument(); + }); + }); + + it('should hide the edit button when the user does not have update permissions', () => { + appMockRender.render( + + + + ); + + expect(screen.getByText('Security banana Issue')).toBeInTheDocument(); + expect(screen.queryByTestId('description-edit-icon')).not.toBeInTheDocument(); + }); + + describe('draft message', () => { + const draftStorageKey = `cases.testAppId.basic-case-id.description.markdownEditor`; + + beforeEach(() => { + sessionStorage.setItem(draftStorageKey, 'value set in storage'); + }); + + it('should show unsaved draft message correctly', async () => { + appMockRender.render(); + + expect(screen.getByTestId('description-unsaved-draft')).toBeInTheDocument(); + }); + + it('should not show unsaved draft message when loading', async () => { + appMockRender.render( + + ); + + expect(screen.queryByTestId('description-unsaved-draft')).not.toBeInTheDocument(); + }); + + it('should not show unsaved draft message when description and storage value are same', async () => { + const props = { + ...defaultProps, + caseData: { ...defaultProps.caseData, description: 'value set in storage' }, + }; + + appMockRender.render(); + + expect(screen.queryByTestId('description-unsaved-draft')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/description/index.tsx b/x-pack/plugins/cases/public/components/description/index.tsx new file mode 100644 index 00000000000000..6ebbf8edd6f458 --- /dev/null +++ b/x-pack/plugins/cases/public/components/description/index.tsx @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { css } from '@emotion/react'; +import { + EuiButtonIcon, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiText, + useEuiTheme, +} from '@elastic/eui'; + +import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; +import * as i18n from '../user_actions/translations'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import { useLensDraftComment } from '../markdown_editor/plugins/lens/use_lens_draft_comment'; +import { EditableMarkdown, ScrollableMarkdown } from '../markdown_editor'; +import type { Case } from '../../containers/types'; +import type { OnUpdateFields } from '../case_view/types'; +import { schema } from './schema'; + +const DESCRIPTION_ID = 'description'; +export interface DescriptionProps { + caseData: Case; + isLoadingDescription: boolean; + onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void; +} + +const DescriptionFooter = styled(EuiFlexItem)` + ${({ theme }) => ` + border-top: ${theme.eui.euiBorderThin}; + padding: ${theme.eui.euiSizeS}; + `} +`; + +const Panel = styled(EuiPanel)` + padding: 0; +`; + +const Header = styled(EuiFlexGroup)` + ${({ theme }) => ` + display: flex; + padding: ${theme.eui.euiSizeS}; + align-items: center; + `} +`; + +const Body = styled(EuiFlexItem)` + ${({ theme }) => ` + padding: ${theme.eui.euiSize}; + padding-top: 0; + + > div { + padding: 0; + } + `} +`; + +const getDraftDescription = ( + applicationId = '', + caseId: string, + commentId: string +): string | null => { + const draftStorageKey = getMarkdownEditorStorageKey(applicationId, caseId, commentId); + + return sessionStorage.getItem(draftStorageKey); +}; + +export const Description = ({ + caseData, + onUpdateField, + isLoadingDescription, +}: DescriptionProps) => { + const [isCollapsed, setIsCollapsed] = useState(false); + const [isEditable, setIsEditable] = useState(false); + + const descriptionRef = useRef(null); + const { euiTheme } = useEuiTheme(); + const { appId, permissions } = useCasesContext(); + + const { + clearDraftComment: clearLensDraftComment, + draftComment: lensDraftComment, + hasIncomingLensState, + } = useLensDraftComment(); + + const handleOnChangeEditable = useCallback(() => { + clearLensDraftComment(); + setIsEditable(false); + }, [setIsEditable, clearLensDraftComment]); + + const handleOnSave = useCallback( + (content: string) => { + onUpdateField({ key: DESCRIPTION_ID, value: content }); + setIsEditable(false); + }, + [onUpdateField, setIsEditable] + ); + + const toggleCollapse = () => setIsCollapsed((oldValue: boolean) => !oldValue); + + const draftDescription = getDraftDescription(appId, caseData.id, DESCRIPTION_ID); + + if ( + hasIncomingLensState && + lensDraftComment !== null && + lensDraftComment?.commentId === DESCRIPTION_ID && + !isEditable + ) { + setIsEditable(true); + } + + const hasUnsavedChanges = + draftDescription && draftDescription !== caseData.description && !isLoadingDescription; + + return isEditable ? ( + + ) : ( + + + +
+ + + {i18n.DESCRIPTION} + + + + + {permissions.update ? ( + setIsEditable(true)} + data-test-subj="description-edit-icon" + /> + ) : null} + + + + + +
+
+ {!isCollapsed ? ( + + + + ) : null} + {hasUnsavedChanges ? ( + + + {i18n.UNSAVED_DRAFT_DESCRIPTION} + + + ) : null} +
+
+ ); +}; + +Description.displayName = 'Description'; diff --git a/x-pack/plugins/cases/public/components/description/schema.ts b/x-pack/plugins/cases/public/components/description/schema.ts new file mode 100644 index 00000000000000..8c47b700adeb5f --- /dev/null +++ b/x-pack/plugins/cases/public/components/description/schema.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { FIELD_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import * as i18n from '../../common/translations'; + +const { emptyField } = fieldValidators; +export interface Content { + content: string; +} +export const schema: FormSchema = { + content: { + type: FIELD_TYPES.TEXTAREA, + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD), + }, + ], + }, +}; diff --git a/x-pack/plugins/cases/public/components/user_actions/markdown_form_footer.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_footer.tsx similarity index 76% rename from x-pack/plugins/cases/public/components/user_actions/markdown_form_footer.tsx rename to x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_footer.tsx index d2c5f692d396b4..86772136c18d00 100644 --- a/x-pack/plugins/cases/public/components/user_actions/markdown_form_footer.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_footer.tsx @@ -12,12 +12,12 @@ import { useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib import * as i18n from '../case_view/translations'; -interface UserActionMarkdownFooterProps { +interface EditableMarkdownFooterProps { handleSaveAction: () => Promise; handleCancelAction: () => void; } -const UserActionMarkdownFooterComponent: React.FC = ({ +const EditableMarkdownFooterComponent: React.FC = ({ handleSaveAction, handleCancelAction, }) => { @@ -27,7 +27,7 @@ const UserActionMarkdownFooterComponent: React.FC ); }; -UserActionMarkdownFooterComponent.displayName = 'UserActionMarkdownFooterComponent'; +EditableMarkdownFooterComponent.displayName = 'EditableMarkdownFooterComponent'; -export const UserActionMarkdownFooter = React.memo(UserActionMarkdownFooterComponent); +export const EditableMarkdownFooter = React.memo(EditableMarkdownFooterComponent); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx new file mode 100644 index 00000000000000..59d424fdd82c88 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx @@ -0,0 +1,220 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { waitFor, fireEvent, screen, render, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { EditableMarkdown } from '.'; +import { TestProviders } from '../../common/mock'; +import type { Content } from '../user_actions/schema'; +import { schema } from '../user_actions/schema'; + +jest.mock('../../common/lib/kibana'); + +const onChangeEditable = jest.fn(); +const onSaveContent = jest.fn(); + +const newValue = 'Hello from Tehas'; +const emptyValue = ''; +const hyperlink = `[hyperlink](http://elastic.co)`; +const draftStorageKey = `cases.testAppId.caseId.markdown-id.markdownEditor`; +const content = `A link to a timeline ${hyperlink}`; + +const editorRef: React.MutableRefObject = { current: null }; +const defaultProps = { + content, + id: 'markdown-id', + caseId: 'caseId', + isEditable: true, + draftStorageKey, + onChangeEditable, + onSaveContent, + fieldName: 'content', + formSchema: schema, + editorRef, +}; + +describe('EditableMarkdown', () => { + const MockHookWrapperComponent: React.FC<{ testProviderProps?: unknown }> = ({ + children, + testProviderProps = {}, + }) => { + const { form } = useForm({ + defaultValue: { content }, + options: { stripEmptyFields: false }, + schema, + }); + + return ( + +
{children}
+
+ ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + sessionStorage.removeItem(draftStorageKey); + }); + + it('Save button click calls onSaveContent and onChangeEditable when text area value changed', async () => { + render( + + + + ); + + fireEvent.change(screen.getByTestId('euiMarkdownEditorTextArea'), { + target: { value: newValue }, + }); + + userEvent.click(screen.getByTestId('editable-save-markdown')); + + await waitFor(() => { + expect(onSaveContent).toHaveBeenCalledWith(newValue); + expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); + }); + }); + + it('Does not call onSaveContent if no change from current text', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('editable-save-markdown')); + + await waitFor(() => { + expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); + }); + expect(onSaveContent).not.toHaveBeenCalled(); + }); + + it('Save button disabled if current text is empty', async () => { + render( + + + + ); + + fireEvent.change(screen.getByTestId('euiMarkdownEditorTextArea'), { value: emptyValue }); + + await waitFor(() => { + expect(screen.getByTestId('editable-save-markdown')).toHaveProperty('disabled'); + }); + }); + + it('Cancel button click calls only onChangeEditable', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('editable-cancel-markdown')); + + await waitFor(() => { + expect(onSaveContent).not.toHaveBeenCalled(); + expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); + }); + }); + + describe('draft comment ', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + sessionStorage.removeItem(draftStorageKey); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('Save button click clears session storage', async () => { + const result = render( + + + + ); + + fireEvent.change(result.getByTestId('euiMarkdownEditorTextArea'), { + target: { value: newValue }, + }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(sessionStorage.getItem(draftStorageKey)).toBe(newValue); + + fireEvent.click(result.getByTestId(`editable-save-markdown`)); + + await waitFor(() => { + expect(onSaveContent).toHaveBeenCalledWith(newValue); + expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); + expect(sessionStorage.getItem(draftStorageKey)).toBe(null); + }); + }); + + it('Cancel button click clears session storage', async () => { + const result = render( + + + + ); + + expect(sessionStorage.getItem(draftStorageKey)).toBe(''); + + fireEvent.change(result.getByTestId('euiMarkdownEditorTextArea'), { + target: { value: newValue }, + }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(sessionStorage.getItem(draftStorageKey)).toBe(newValue); + }); + + fireEvent.click(result.getByTestId('editable-cancel-markdown')); + + await waitFor(() => { + expect(sessionStorage.getItem(draftStorageKey)).toBe(null); + }); + }); + + describe('existing storage key', () => { + beforeEach(() => { + sessionStorage.setItem(draftStorageKey, 'value set in storage'); + }); + + it('should have session storage value same as draft comment', async () => { + const result = render( + + + + ); + + expect(result.getByText('value set in storage')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.tsx new file mode 100644 index 00000000000000..1706e30fcc8f4d --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { forwardRef, useCallback, useImperativeHandle } from 'react'; + +import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { Form, UseField, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { MarkdownEditorForm } from '.'; +import { removeItemFromSessionStorage } from '../utils'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import { getMarkdownEditorStorageKey } from './utils'; +import { EditableMarkdownFooter } from './editable_markdown_footer'; + +export interface EditableMarkdownRefObject { + setComment: (newComment: string) => void; +} +interface EditableMarkdownRendererProps { + content: string; + id: string; + caseId: string; + fieldName: string; + onChangeEditable: (id: string) => void; + onSaveContent: (content: string) => void; + editorRef: React.MutableRefObject; + formSchema: FormSchema<{ content: string }> | undefined; +} + +const EditableMarkDownRenderer = forwardRef< + EditableMarkdownRefObject, + EditableMarkdownRendererProps +>( + ( + { id, content, caseId, fieldName, onChangeEditable, onSaveContent, editorRef, formSchema }, + ref + ) => { + const { appId } = useCasesContext(); + const draftStorageKey = getMarkdownEditorStorageKey(appId, caseId, id); + const initialState = { content }; + + const { form } = useForm({ + defaultValue: initialState, + options: { stripEmptyFields: false }, + schema: formSchema, + }); + const { submit, setFieldValue } = form; + + const setComment = useCallback( + (newComment) => { + setFieldValue(fieldName, newComment); + }, + [setFieldValue, fieldName] + ); + + useImperativeHandle(ref, () => ({ + setComment, + editor: editorRef.current, + })); + + const handleCancelAction = useCallback(() => { + onChangeEditable(id); + removeItemFromSessionStorage(draftStorageKey); + }, [id, onChangeEditable, draftStorageKey]); + + const handleSaveAction = useCallback(async () => { + const { isValid, data } = await submit(); + + if (isValid && data.content !== content) { + onSaveContent(data.content); + } + onChangeEditable(id); + removeItemFromSessionStorage(draftStorageKey); + }, [content, id, onChangeEditable, onSaveContent, submit, draftStorageKey]); + + return ( +
+ + ), + initialValue: content, + }} + /> + + ); + } +); + +EditableMarkDownRenderer.displayName = 'EditableMarkDownRenderer'; + +export const EditableMarkdown = React.memo(EditableMarkDownRenderer); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/index.tsx b/x-pack/plugins/cases/public/components/markdown_editor/index.tsx index e77a36d48f7d95..214779263f7736 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/index.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/index.tsx @@ -9,3 +9,5 @@ export * from './types'; export * from './renderer'; export * from './editor'; export * from './eui_form'; +export * from './scrollable_markdown_renderer'; +export * from './editable_markdown_renderer'; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.test.tsx new file mode 100644 index 00000000000000..05ea034e776a37 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.test.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; + +import { ScrollableMarkdown } from '.'; + +const content = 'This is sample content'; + +describe('ScrollableMarkdown', () => { + let appMockRenderer: AppMockRenderer; + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + it('renders correctly', () => { + appMockRenderer.render(); + expect(screen.getByTestId('scrollable-markdown')).toBeInTheDocument(); + expect(screen.getByText(content)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.tsx b/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.tsx new file mode 100644 index 00000000000000..dd5ab2ec8241a2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import styled from 'styled-components'; + +import { MarkdownRenderer } from './renderer'; + +export const ContentWrapper = styled.div` + padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`}; + text-overflow: ellipsis; + word-break: break-word; + display: -webkit-box; + -webkit-box-orient: vertical; +`; + +const ScrollableMarkdownRenderer = ({ content }: { content: string }) => { + return ( + + {content} + + ); +}; + +ScrollableMarkdownRenderer.displayName = 'ScrollableMarkdownRenderer'; + +export const ScrollableMarkdown = React.memo(ScrollableMarkdownRenderer); diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/actions.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/actions.tsx index dccf5ae0b91a28..d4086805d7cd9a 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/actions.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/actions.tsx @@ -13,8 +13,7 @@ import type { UserActionBuilder, UserActionBuilderArgs } from '../types'; import { UserActionTimestamp } from '../timestamp'; import type { SnakeToCamelCase } from '../../../../common/types'; import { UserActionCopyLink } from '../copy_link'; -import { MarkdownRenderer } from '../../markdown_editor'; -import { ContentWrapper } from '../markdown_form'; +import { ScrollableMarkdown } from '../../markdown_editor'; import { HostIsolationCommentEvent } from './host_isolation_event'; import { HoverableUserWithAvatarResolver } from '../../user_profiles/hoverable_user_with_avatar_resolver'; @@ -57,9 +56,7 @@ export const createActionAttachmentUserActionBuilder = ({ timelineAvatarAriaLabel: actionIconName, actions: , children: comment.comment.trim().length > 0 && ( - - {comment.comment} - + ), }, ]; diff --git a/x-pack/plugins/cases/public/components/user_actions/description.test.tsx b/x-pack/plugins/cases/public/components/user_actions/description.test.tsx index 0f0d284130519f..d7f393f987b926 100644 --- a/x-pack/plugins/cases/public/components/user_actions/description.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/description.test.tsx @@ -7,65 +7,27 @@ import React from 'react'; import { EuiCommentList } from '@elastic/eui'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { Actions } from '../../../common/api'; import { getUserAction } from '../../containers/mock'; import { TestProviders } from '../../common/mock'; -import { createDescriptionUserActionBuilder, getDescriptionUserAction } from './description'; +import { createDescriptionUserActionBuilder } from './description'; import { getMockBuilderArgs } from './mock'; -import userEvent from '@testing-library/user-event'; jest.mock('../../common/lib/kibana'); jest.mock('../../common/navigation/hooks'); describe('createDescriptionUserActionBuilder ', () => { - const onUpdateField = jest.fn(); const builderArgs = getMockBuilderArgs(); beforeEach(() => { jest.clearAllMocks(); }); - it('renders correctly description', async () => { - const descriptionUserAction = getDescriptionUserAction({ - ...builderArgs, - onUpdateField, - isLoadingDescription: false, - }); - - render( - - - - ); - - expect(screen.getByText('added description')).toBeInTheDocument(); - expect(screen.getByText('Security banana Issue')).toBeInTheDocument(); - }); - - it('edits the description correctly', async () => { - const descriptionUserAction = getDescriptionUserAction({ - ...builderArgs, - onUpdateField, - isLoadingDescription: false, - }); - - const res = render( - - - - ); - - userEvent.click(res.getByTestId('editable-description-edit-icon')); - - await waitFor(() => { - expect(builderArgs.handleManageMarkdownEditId).toHaveBeenCalledWith('description'); - }); - }); - - it('renders correctly when editing a description', async () => { + it('renders correctly', async () => { const userAction = getUserAction('description', Actions.update); + // @ts-ignore no need to pass all the arguments const builder = createDescriptionUserActionBuilder({ ...builderArgs, userAction, diff --git a/x-pack/plugins/cases/public/components/user_actions/description.tsx b/x-pack/plugins/cases/public/components/user_actions/description.tsx index 236252927ad6e3..42deabb9f13465 100644 --- a/x-pack/plugins/cases/public/components/user_actions/description.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/description.tsx @@ -5,108 +5,12 @@ * 2.0. */ -import React from 'react'; -import classNames from 'classnames'; -import type { EuiCommentProps } from '@elastic/eui'; -import styled from 'styled-components'; -import { EuiText, EuiButtonIcon } from '@elastic/eui'; - -import type { UserActionBuilder, UserActionBuilderArgs, UserActionTreeProps } from './types'; -import { createCommonUpdateUserActionBuilder } from './common'; -import { UserActionTimestamp } from './timestamp'; -import { UserActionMarkdown } from './markdown_form'; -import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; +import type { UserActionBuilder } from './types'; import * as i18n from './translations'; -import { HoverableUsernameResolver } from '../user_profiles/hoverable_username_resolver'; - -const DESCRIPTION_ID = 'description'; +import { createCommonUpdateUserActionBuilder } from './common'; const getLabelTitle = () => `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`; -type GetDescriptionUserActionArgs = Pick< - UserActionBuilderArgs, - | 'caseData' - | 'commentRefs' - | 'userProfiles' - | 'manageMarkdownEditIds' - | 'handleManageMarkdownEditId' - | 'appId' -> & - Pick & { isLoadingDescription: boolean }; - -const MyEuiCommentFooter = styled(EuiText)` - ${({ theme }) => ` - border-top: ${theme.eui.euiBorderThin}; - padding: ${theme.eui.euiSizeS}; - `} -`; - -const hasDraftComment = (appId = '', caseId: string, commentId: string): boolean => { - const draftStorageKey = getMarkdownEditorStorageKey(appId, caseId, commentId); - - return Boolean(sessionStorage.getItem(draftStorageKey)); -}; - -export const getDescriptionUserAction = ({ - appId, - caseData, - commentRefs, - manageMarkdownEditIds, - isLoadingDescription, - userProfiles, - onUpdateField, - handleManageMarkdownEditId, -}: GetDescriptionUserActionArgs): EuiCommentProps => { - const isEditable = manageMarkdownEditIds.includes(DESCRIPTION_ID); - return { - username: , - event: i18n.ADDED_DESCRIPTION, - 'data-test-subj': 'description-action', - timestamp: , - children: ( - <> - (commentRefs.current[DESCRIPTION_ID] = element)} - caseId={caseData.id} - id={DESCRIPTION_ID} - content={caseData.description} - isEditable={isEditable} - onSaveContent={(content: string) => { - onUpdateField({ key: DESCRIPTION_ID, value: content }); - }} - onChangeEditable={handleManageMarkdownEditId} - /> - {!isEditable && - !isLoadingDescription && - hasDraftComment(appId, caseData.id, DESCRIPTION_ID) ? ( - - - {i18n.UNSAVED_DRAFT_DESCRIPTION} - - - ) : ( - '' - )} - - ), - timelineAvatar: null, - className: classNames({ - isEdit: manageMarkdownEditIds.includes(DESCRIPTION_ID), - draftFooter: - !isEditable && !isLoadingDescription && hasDraftComment(appId, caseData.id, DESCRIPTION_ID), - }), - actions: ( - handleManageMarkdownEditId(DESCRIPTION_ID)} - data-test-subj="editable-description-edit-icon" - /> - ), - }; -}; - export const createDescriptionUserActionBuilder: UserActionBuilder = ({ userAction, userProfiles, diff --git a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx index 5008fc99c0f01e..47f0bd6ab49391 100644 --- a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx @@ -171,14 +171,14 @@ describe(`UserActions`, () => { userEvent.click( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).getByTestId('user-action-cancel-markdown') + ).getByTestId('editable-cancel-markdown') ); await waitFor(() => { expect( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).queryByTestId('user-action-markdown-form') + ).queryByTestId('editable-markdown-form') ).not.toBeInTheDocument(); }); }); @@ -212,14 +212,14 @@ describe(`UserActions`, () => { userEvent.click( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).getByTestId('user-action-save-markdown') + ).getByTestId('editable-save-markdown') ); await waitFor(() => { expect( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).queryByTestId('user-action-markdown-form') + ).queryByTestId('editable-markdown-form') ).not.toBeInTheDocument(); expect(patchComment).toBeCalledWith({ @@ -306,14 +306,14 @@ describe(`UserActions`, () => { userEvent.click( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).getByTestId('user-action-save-markdown') + ).getByTestId('editable-save-markdown') ); await waitFor(() => { expect( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).queryByTestId('user-action-markdown-form') + ).queryByTestId('editable-markdown-form') ).not.toBeInTheDocument(); }); diff --git a/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx b/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx index 972a520d2085c5..a52d1bf221119d 100644 --- a/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx @@ -6,20 +6,19 @@ */ import React from 'react'; -import { mount } from 'enzyme'; -import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import type { Content } from './schema'; -import { schema } from './schema'; -import { UserActionMarkdown } from './markdown_form'; -import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer, TestProviders } from '../../common/mock'; -import { waitFor, fireEvent, render, act } from '@testing-library/react'; +import { waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { UserActionMarkdown } from './markdown_form'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); + const onChangeEditable = jest.fn(); const onSaveContent = jest.fn(); -const newValue = 'Hello from Tehas'; -const emptyValue = ''; const hyperlink = `[hyperlink](http://elastic.co)`; const draftStorageKey = `cases.testAppId.caseId.markdown-id.markdownEditor`; const defaultProps = { @@ -33,8 +32,10 @@ const defaultProps = { }; describe('UserActionMarkdown ', () => { + let appMockRenderer: AppMockRenderer; beforeEach(() => { jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); }); afterEach(() => { @@ -42,147 +43,26 @@ describe('UserActionMarkdown ', () => { }); it('Renders markdown correctly when not in edit mode', async () => { - const wrapper = mount( - - - - ); + appMockRenderer.render(); - expect(wrapper.find(`[data-test-subj="markdown-link"]`).first().text()).toContain('hyperlink'); + expect(screen.getByTestId('scrollable-markdown')).toBeInTheDocument(); + expect(screen.getByTestId('markdown-link')).toBeInTheDocument(); + expect(screen.queryByTestId('editable-save-markdown')).not.toBeInTheDocument(); + expect(screen.queryByTestId('editable-cancel-markdown')).not.toBeInTheDocument(); }); - it('Save button click calls onSaveContent and onChangeEditable when text area value changed', async () => { - const wrapper = mount( - - - - ); + it('Renders markdown correctly when in edit mode', async () => { + appMockRenderer.render(); - wrapper - .find(`.euiMarkdownEditorTextArea`) - .first() - .simulate('change', { - target: { value: newValue }, - }); - - wrapper.find(`button[data-test-subj="user-action-save-markdown"]`).first().simulate('click'); - - await waitFor(() => { - expect(onSaveContent).toHaveBeenCalledWith(newValue); - expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); - }); - }); - - it('Does not call onSaveContent if no change from current text', async () => { - const wrapper = mount( - - - - ); - - wrapper.find(`button[data-test-subj="user-action-save-markdown"]`).first().simulate('click'); - - await waitFor(() => { - expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); - }); - expect(onSaveContent).not.toHaveBeenCalled(); - }); - - it('Save button disabled if current text is empty', async () => { - const wrapper = mount( - - - - ); - - wrapper - .find(`.euiMarkdownEditorTextArea`) - .first() - .simulate('change', { - target: { value: emptyValue }, - }); - - await waitFor(() => { - expect( - wrapper.find(`button[data-test-subj="user-action-save-markdown"]`).first().prop('disabled') - ).toBeTruthy(); - }); - }); - - it('Cancel button click calls only onChangeEditable', async () => { - const wrapper = mount( - - - - ); - wrapper.find(`[data-test-subj="user-action-cancel-markdown"]`).first().simulate('click'); - - await waitFor(() => { - expect(onSaveContent).not.toHaveBeenCalled(); - expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); - }); + expect(screen.getByTestId('editable-save-markdown')).toBeInTheDocument(); + expect(screen.getByTestId('editable-cancel-markdown')).toBeInTheDocument(); }); describe('useForm stale state bug', () => { - let appMockRenderer: AppMockRenderer; const oldContent = defaultProps.content; const appendContent = ' appended content'; const newContent = defaultProps.content + appendContent; - beforeEach(() => { - appMockRenderer = createAppMockRenderer(); - }); - - it('creates a stale state if a key is not passed to the component', async () => { - const TestComponent = () => { - const [isEditable, setIsEditable] = React.useState(true); - const [saveContent, setSaveContent] = React.useState(defaultProps.content); - return ( -
- -
- ); - }; - - const result = appMockRenderer.render(); - - expect(result.getByTestId('user-action-markdown-form')).toBeTruthy(); - - // append some content and save - userEvent.type(result.container.querySelector('textarea')!, appendContent); - userEvent.click(result.getByTestId('user-action-save-markdown')); - - // wait for the state to update - await waitFor(() => { - expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); - }); - - // toggle to non-edit state - userEvent.click(result.getByTestId('test-button')); - expect(result.getByTestId('user-action-markdown')).toBeTruthy(); - - // toggle to edit state again - userEvent.click(result.getByTestId('test-button')); - - // the text area holds a stale value - // this is the wrong behaviour. The textarea holds the old content - expect(result.container.querySelector('textarea')!.value).toEqual(oldContent); - expect(result.container.querySelector('textarea')!.value).not.toEqual(newContent); - }); - it("doesn't create a stale state if a key is passed to the component", async () => { const TestComponent = () => { const [isEditable, setIsEditable] = React.useState(true); @@ -208,11 +88,11 @@ describe('UserActionMarkdown ', () => { ); }; const result = appMockRenderer.render(); - expect(result.getByTestId('user-action-markdown-form')).toBeTruthy(); + expect(result.getByTestId('editable-markdown-form')).toBeTruthy(); // append content and save userEvent.type(result.container.querySelector('textarea')!, appendContent); - userEvent.click(result.getByTestId('user-action-save-markdown')); + userEvent.click(result.getByTestId('editable-save-markdown')); // wait for the state to update await waitFor(() => { @@ -221,7 +101,7 @@ describe('UserActionMarkdown ', () => { // toggle to non-edit state userEvent.click(result.getByTestId('test-button')); - expect(result.getByTestId('user-action-markdown')).toBeTruthy(); + expect(result.getByTestId('scrollable-markdown')).toBeTruthy(); // toggle to edit state again userEvent.click(result.getByTestId('test-button')); @@ -231,112 +111,4 @@ describe('UserActionMarkdown ', () => { expect(result.container.querySelector('textarea')!.value).not.toEqual(oldContent); }); }); - - describe('draft comment ', () => { - const content = 'test content'; - const initialState = { content }; - const MockHookWrapperComponent: React.FC<{ testProviderProps?: unknown }> = ({ - children, - testProviderProps = {}, - }) => { - const { form } = useForm({ - defaultValue: initialState, - options: { stripEmptyFields: false }, - schema, - }); - - return ( - -
{children}
-
- ); - }; - - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.clearAllTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); - sessionStorage.removeItem(draftStorageKey); - }); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('Save button click clears session storage', async () => { - const result = render( - - - - ); - - fireEvent.change(result.getByTestId('euiMarkdownEditorTextArea'), { - target: { value: newValue }, - }); - - act(() => { - jest.advanceTimersByTime(1000); - }); - - expect(sessionStorage.getItem(draftStorageKey)).toBe(newValue); - - fireEvent.click(result.getByTestId(`user-action-save-markdown`)); - - await waitFor(() => { - expect(onSaveContent).toHaveBeenCalledWith(newValue); - expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); - expect(sessionStorage.getItem(draftStorageKey)).toBe(null); - }); - }); - - it('Cancel button click clears session storage', async () => { - const result = render( - - - - ); - - expect(sessionStorage.getItem(draftStorageKey)).toBe(''); - - fireEvent.change(result.getByTestId('euiMarkdownEditorTextArea'), { - target: { value: newValue }, - }); - - act(() => { - jest.advanceTimersByTime(1000); - }); - - await waitFor(() => { - expect(sessionStorage.getItem(draftStorageKey)).toBe(newValue); - }); - - fireEvent.click(result.getByTestId('user-action-cancel-markdown')); - - await waitFor(() => { - expect(sessionStorage.getItem(draftStorageKey)).toBe(null); - }); - }); - - describe('existing storage key', () => { - beforeEach(() => { - sessionStorage.setItem(draftStorageKey, 'value set in storage'); - }); - - it('should have session storage value same as draft comment', async () => { - const result = render( - - - - ); - - expect(result.getByText('value set in storage')).toBeInTheDocument(); - }); - }); - }); }); diff --git a/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx b/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx index 3ddb62eb4fb7aa..3866fe774ec14f 100644 --- a/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx @@ -5,25 +5,10 @@ * 2.0. */ -import React, { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; -import styled from 'styled-components'; +import React, { forwardRef, useRef } from 'react'; -import { Form, useForm, UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import type { Content } from './schema'; import { schema } from './schema'; -import { MarkdownRenderer, MarkdownEditorForm } from '../markdown_editor'; -import { removeItemFromSessionStorage } from '../utils'; -import { useCasesContext } from '../cases_context/use_cases_context'; -import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; -import { UserActionMarkdownFooter } from './markdown_form_footer'; - -export const ContentWrapper = styled.div` - padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`}; - text-overflow: ellipsis; - word-break: break-word; - display: -webkit-box; - -webkit-box-orient: vertical; -`; +import { ScrollableMarkdown, EditableMarkdown } from '../markdown_editor'; interface UserActionMarkdownProps { content: string; @@ -43,70 +28,22 @@ const UserActionMarkdownComponent = forwardRef< UserActionMarkdownProps >(({ id, content, caseId, isEditable, onChangeEditable, onSaveContent }, ref) => { const editorRef = useRef(); - const initialState = { content }; - const { form } = useForm({ - defaultValue: initialState, - options: { stripEmptyFields: false }, - schema, - }); - const fieldName = 'content'; - const { appId } = useCasesContext(); - const draftStorageKey = getMarkdownEditorStorageKey(appId, caseId, id); - const { setFieldValue, submit } = form; - - const handleCancelAction = useCallback(() => { - onChangeEditable(id); - removeItemFromSessionStorage(draftStorageKey); - }, [id, onChangeEditable, draftStorageKey]); - - const handleSaveAction = useCallback(async () => { - const { isValid, data } = await submit(); - - if (isValid && data.content !== content) { - onSaveContent(data.content); - } - onChangeEditable(id); - removeItemFromSessionStorage(draftStorageKey); - }, [content, id, onChangeEditable, onSaveContent, submit, draftStorageKey]); - - const setComment = useCallback( - (newComment) => { - setFieldValue(fieldName, newComment); - }, - [setFieldValue] - ); - - useImperativeHandle(ref, () => ({ - setComment, - editor: editorRef.current, - })); return isEditable ? ( -
- - ), - initialValue: content, - }} - /> - + ) : ( - - {content} - + ); }); diff --git a/x-pack/plugins/security_solution/cypress/e2e/cases/creation.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/cases/creation.cy.ts index 46bfd1f388ea68..260ef5a393b3ed 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/cases/creation.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/cases/creation.cy.ts @@ -26,7 +26,6 @@ import { CASE_DETAILS_PAGE_TITLE, CASE_DETAILS_STATUS, CASE_DETAILS_TAGS, - CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME, CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT, CASE_DETAILS_USERNAMES, PARTICIPANTS, @@ -99,8 +98,7 @@ describe('Cases', () => { const expectedTags = this.mycase.tags.join(''); cy.get(CASE_DETAILS_PAGE_TITLE).should('have.text', this.mycase.name); cy.get(CASE_DETAILS_STATUS).should('have.text', 'Open'); - cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME).should('have.text', this.mycase.reporter); - cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT).should('have.text', 'added description'); + cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT).should('have.text', 'Description'); cy.get(CASE_DETAILS_DESCRIPTION).should( 'have.text', `${this.mycase.description} ${this.mycase.timeline.title}` diff --git a/x-pack/plugins/security_solution/cypress/screens/case_details.ts b/x-pack/plugins/security_solution/cypress/screens/case_details.ts index fcd8b60557fc17..271ef54922d5d5 100644 --- a/x-pack/plugins/security_solution/cypress/screens/case_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/case_details.ts @@ -12,7 +12,7 @@ export const CASE_CONNECTOR = '[data-test-subj="connector-fields"] .euiCard__tit export const CASE_DELETE = '[data-test-subj="property-actions-trash"]'; export const CASE_DETAILS_DESCRIPTION = - '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]'; + '[data-test-subj="description"] [data-test-subj="scrollable-markdown"]'; export const CASE_DETAILS_PAGE_TITLE = '[data-test-subj="header-page-title"]'; @@ -21,13 +21,10 @@ export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status-dropdown"] export const CASE_DETAILS_TAGS = '[data-test-subj="case-tags"]'; export const CASE_DETAILS_TIMELINE_LINK_MARKDOWN = - '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"] button'; + '[data-test-subj="description"] [data-test-subj="scrollable-markdown"] button'; export const CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT = - '[data-test-subj="description-action"] .euiCommentEvent__headerEvent'; - -export const CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME = - '[data-test-subj="description-action"] .euiCommentEvent__headerUsername'; + '[data-test-subj="description"] [data-test-subj="description-title"]'; export const CASE_DETAILS_USERNAMES = '[data-test-subj="user-profile-username"]'; @@ -41,7 +38,7 @@ export const CASES_TAGS = (tagName: string) => { return `[data-test-subj="tag-${tagName}"]`; }; -export const CASE_USER_ACTION = '[data-test-subj="user-action-markdown"]'; +export const CASE_USER_ACTION = '[data-test-subj="scrollable-markdown"]'; export const CONNECTOR_CARD_DETAILS = '[data-test-subj="connector-card-details"]'; diff --git a/x-pack/test/functional/services/cases/single_case_view.ts b/x-pack/test/functional/services/cases/single_case_view.ts index f5c34feedf8119..237881f2af78b4 100644 --- a/x-pack/test/functional/services/cases/single_case_view.ts +++ b/x-pack/test/functional/services/cases/single_case_view.ts @@ -101,7 +101,7 @@ export function CasesSingleViewServiceProvider({ getService, getPageObject }: Ft async assertCaseDescription(expectedDescription: string) { const desc = await find.byCssSelector( - '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj="description"] [data-test-subj="scrollable-markdown"]' ); const actualDescription = await desc.getVisibleText(); diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts index 40cbbc53c55968..8823f5144a0fc6 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts @@ -51,7 +51,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { expect(await title.getVisibleText()).equal(caseTitle); // validate description - const description = await testSubjects.find('user-action-markdown'); + const description = await testSubjects.find('scrollable-markdown'); expect(await description.getVisibleText()).equal('test description'); // validate tag exists diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts index d0df92ac4f7e79..233157e57e5187 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts @@ -58,7 +58,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // validate user action const newComment = await find.byCssSelector( - '[data-test-subj*="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj*="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); expect(await newComment.getVisibleText()).equal('Test comment from automation'); }); @@ -207,7 +207,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // validate user action const newComment = await find.byCssSelector( - '[data-test-subj*="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj*="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); expect(await newComment.getVisibleText()).equal('Test comment from automation'); }); @@ -244,7 +244,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // validate user action const newComment = await find.byCssSelector( - '[data-test-subj*="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj*="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); expect(await newComment.getVisibleText()).equal('Test comment from automation'); }); @@ -266,7 +266,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // validate user action const newComment = await find.byCssSelector( - '[data-test-subj*="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj*="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); expect(await newComment.getVisibleText()).equal('Test comment from automation'); }); @@ -291,7 +291,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await header.waitUntilLoadingHasFinished(); const editCommentTextArea = await find.byCssSelector( - '[data-test-subj*="user-action-markdown-form"] textarea.euiMarkdownEditorTextArea' + '[data-test-subj*="editable-markdown-form"] textarea.euiMarkdownEditorTextArea' ); await header.waitUntilLoadingHasFinished(); @@ -307,12 +307,12 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('shows unsaved description message when page is refreshed', async () => { - await testSubjects.click('editable-description-edit-icon'); + await testSubjects.click('description-edit-icon'); await header.waitUntilLoadingHasFinished(); const editCommentTextArea = await find.byCssSelector( - '[data-test-subj*="user-action-markdown-form"] textarea.euiMarkdownEditorTextArea' + '[data-test-subj*="editable-markdown-form"] textarea.euiMarkdownEditorTextArea' ); await header.waitUntilLoadingHasFinished(); @@ -320,6 +320,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await editCommentTextArea.focus(); await editCommentTextArea.type('Edited description'); + await header.waitUntilLoadingHasFinished(); + await browser.refresh(); await header.waitUntilLoadingHasFinished(); diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group2/upgrade.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group2/upgrade.ts index 561bbae70bcca0..93d12b5d908daf 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group2/upgrade.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group2/upgrade.ts @@ -86,7 +86,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('shows the description correctly', async () => { const desc = await find.byCssSelector( - '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj="description"] [data-test-subj="scrollable-markdown"]' ); expect(await desc.getVisibleText()).equal(`Testing upgrade! Let's see how it goes.`); @@ -112,7 +112,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('shows the first comment correctly', async () => { const comment = await find.byCssSelector( - '[data-test-subj^="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj^="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); expect(await comment.getVisibleText()).equal(`This is interesting. I am curious also.`); @@ -127,7 +127,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('shows the second comment correctly', async () => { const comments = await find.allByCssSelector( - '[data-test-subj^="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj^="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); const secondComment = comments[1]; @@ -140,7 +140,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('shows the third comment correctly', async () => { const comments = await find.allByCssSelector( - '[data-test-subj^="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj^="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); const thirdComment = comments[2]; @@ -200,7 +200,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('shows the fourth comment correctly', async () => { const comments = await find.allByCssSelector( - '[data-test-subj^="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj^="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); const thirdComment = comments[3]; From 5ed5fd4555c378778bc8fc06e5aa3c35899ee64e Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Tue, 25 Apr 2023 16:11:32 +0200 Subject: [PATCH 03/13] [Rollup Jobs] Migrate EuiCodeEditor to EuiCodeBlock (#155494) --- .../components/job_details/tabs/tab_json.js | 16 ++++------------ .../job_list/detail_panel/detail_panel.test.js | 5 ++--- x-pack/plugins/rollup/public/shared_imports.ts | 7 +------ 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/rollup/public/crud_app/sections/components/job_details/tabs/tab_json.js b/x-pack/plugins/rollup/public/crud_app/sections/components/job_details/tabs/tab_json.js index adfb9aafc444e7..daba32a3b9520e 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/components/job_details/tabs/tab_json.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/components/job_details/tabs/tab_json.js @@ -6,22 +6,14 @@ */ import React from 'react'; - -import { EuiCodeEditor } from '../../../../../shared_imports'; +import { EuiCodeBlock } from '@elastic/eui'; export const TabJson = ({ json }) => { const jsonString = JSON.stringify(json, null, 2); return ( - + + {jsonString} + ); }; diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js index fa63639ef4d064..dc38bd3af9afdd 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js @@ -322,9 +322,8 @@ describe('', () => { const tabContent = find('rollupJobDetailTabContent'); it('should render the "EuiCodeEditor" with the job "json" data', () => { - const euiCodeEditor = tabContent.find('EuiCodeEditor'); - expect(euiCodeEditor.length).toBeTruthy(); - expect(JSON.parse(euiCodeEditor.props().value)).toEqual(defaultJob.json); + const euiCodeEditor = tabContent.find('[data-test-subj="jsonCodeBlock"]').at(0); + expect(JSON.parse(euiCodeEditor.text())).toEqual(defaultJob.json); }); }); }); diff --git a/x-pack/plugins/rollup/public/shared_imports.ts b/x-pack/plugins/rollup/public/shared_imports.ts index cee1fe39bc16ac..ed0d444ccb1605 100644 --- a/x-pack/plugins/rollup/public/shared_imports.ts +++ b/x-pack/plugins/rollup/public/shared_imports.ts @@ -5,12 +5,7 @@ * 2.0. */ -export { - extractQueryParams, - indices, - SectionLoading, - EuiCodeEditor, -} from '@kbn/es-ui-shared-plugin/public'; +export { extractQueryParams, indices, SectionLoading } from '@kbn/es-ui-shared-plugin/public'; export { KibanaContextProvider, From ae76801a2e310c5bc67067c32123350588f8d6dd Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Tue, 25 Apr 2023 16:11:57 +0200 Subject: [PATCH 04/13] [Index Management] Migrate code editors to Monaco (#155598) --- .../template_create.test.tsx | 17 +++++++++++++ .../template_edit.test.tsx | 17 +++++++++++++ .../template_form.helpers.ts | 22 +++++----------- .../component_template_create.test.tsx | 17 +++++++++++++ .../component_template_edit.test.tsx | 17 +++++++++++++ .../component_template_form.helpers.ts | 24 +++++++++--------- .../components/wizard_steps/step_aliases.tsx | 25 ++++++++----------- .../components/wizard_steps/step_settings.tsx | 25 ++++++++----------- 8 files changed, 106 insertions(+), 58 deletions(-) diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx index 1727caa9eaff04..2eb74031772572 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx @@ -21,6 +21,23 @@ import { import { setup } from './template_create.helpers'; import { TemplateFormTestBed } from './template_form.helpers'; +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + // Mocking CodeEditor, which uses React Monaco under the hood + CodeEditor: (props: any) => ( + ) => { + props.onChange(e.currentTarget.getAttribute('data-currentvalue')); + }} + /> + ), + }; +}); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx index 99565222ffa7b9..6d1224abf35297 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx @@ -28,6 +28,23 @@ const MAPPING = { }, }; +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + // Mocking CodeEditor, which uses React Monaco under the hood + CodeEditor: (props: any) => ( + ) => { + props.onChange(e.currentTarget.getAttribute('data-currentvalue')); + }} + /> + ), + }; +}); + jest.mock('@elastic/eui', () => { const origial = jest.requireActual('@elastic/eui'); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts index 64ce73ee8161b0..bf16e8e5e803d5 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts @@ -219,18 +219,13 @@ export const formSetup = async (initTestBed: SetupFunc) => { const completeStepThree = async (settings?: string) => { const { find, component } = testBed; - await act(async () => { - if (settings) { - find('settingsEditor').simulate('change', { - jsonString: settings, - }); // Using mocked EuiCodeEditor - jest.advanceTimersByTime(0); - } - }); + if (settings) { + find('settingsEditor').getDOMNode().setAttribute('data-currentvalue', settings); + find('settingsEditor').simulate('change'); + } await act(async () => { clickNextButton(); - jest.advanceTimersByTime(0); }); component.update(); @@ -258,13 +253,8 @@ export const formSetup = async (initTestBed: SetupFunc) => { const { find, component } = testBed; if (aliases) { - await act(async () => { - find('aliasesEditor').simulate('change', { - jsonString: aliases, - }); // Using mocked EuiCodeEditor - jest.advanceTimersByTime(0); // advance timers to allow the form to validate - }); - component.update(); + find('aliasesEditor').getDOMNode().setAttribute('data-currentvalue', aliases); + find('aliasesEditor').simulate('change'); } await act(async () => { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx index 5e915c526dc441..8309db0699fc6a 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx @@ -14,6 +14,23 @@ import { setupEnvironment } from './helpers'; import { API_BASE_PATH } from './helpers/constants'; import { setup, ComponentTemplateCreateTestBed } from './helpers/component_template_create.helpers'; +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + // Mocking CodeEditor, which uses React Monaco under the hood + CodeEditor: (props: any) => ( + ) => { + props.onChange(e.currentTarget.getAttribute('data-currentvalue')); + }} + /> + ), + }; +}); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx index 94beecf441b074..1da8027c59497a 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx @@ -13,6 +13,23 @@ import { setupEnvironment } from './helpers'; import { API_BASE_PATH } from './helpers/constants'; import { setup, ComponentTemplateEditTestBed } from './helpers/component_template_edit.helpers'; +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + // Mocking CodeEditor, which uses React Monaco under the hood + CodeEditor: (props: any) => ( + ) => { + props.onChange(e.currentTarget.getAttribute('data-currentvalue')); + }} + /> + ), + }; +}); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts index 2aaadf3c06f117..0db29dffff5107 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts @@ -66,14 +66,14 @@ export const getFormActions = (testBed: TestBed) => { const completeStepSettings = async (settings?: { [key: string]: any }) => { const { find, component } = testBed; + const settingsValue = JSON.stringify(settings); - await act(async () => { - if (settings) { - find('settingsEditor').simulate('change', { - jsonString: JSON.stringify(settings), - }); // Using mocked EuiCodeEditor - } + if (settingsValue) { + find('settingsEditor').getDOMNode().setAttribute('data-currentvalue', settingsValue); + find('settingsEditor').simulate('change'); + } + await act(async () => { clickNextButton(); }); @@ -119,14 +119,14 @@ export const getFormActions = (testBed: TestBed) => { const completeStepAliases = async (aliases?: { [key: string]: any }) => { const { find, component } = testBed; + const aliasesValue = JSON.stringify(aliases); - await act(async () => { - if (aliases) { - find('aliasesEditor').simulate('change', { - jsonString: JSON.stringify(aliases), - }); // Using mocked EuiCodeEditor - } + if (aliasesValue) { + find('aliasesEditor').getDOMNode().setAttribute('data-currentvalue', aliasesValue); + find('aliasesEditor').simulate('change'); + } + await act(async () => { clickNextButton(); }); diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx index b22030be2d30b9..a948b9a999fa80 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx @@ -18,8 +18,9 @@ import { EuiCode, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { CodeEditor } from '@kbn/kibana-react-plugin/public'; -import { EuiCodeEditor, Forms } from '../../../../../shared_imports'; +import { Forms } from '../../../../../shared_imports'; import { useJsonStep } from './use_json_step'; interface Props { @@ -105,29 +106,23 @@ export const StepAliases: React.FunctionComponent = React.memo( error={error} fullWidth > - diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx index f370ea3642491e..0fd889de039212 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx @@ -18,8 +18,9 @@ import { EuiCode, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { CodeEditor } from '@kbn/kibana-react-plugin/public'; -import { EuiCodeEditor, Forms } from '../../../../../shared_imports'; +import { Forms } from '../../../../../shared_imports'; import { useJsonStep } from './use_json_step'; interface Props { @@ -99,29 +100,23 @@ export const StepSettings: React.FunctionComponent = React.memo( error={error} fullWidth > - From bd35be27cafb03f7df4a43f203780e4374e409e2 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Tue, 25 Apr 2023 16:17:49 +0200 Subject: [PATCH 05/13] [ML] Transforms: Replace `es_search` API endpoint with `data.search` (#154898) Replaces the custom `es_search` API endpoint with using `data.search`. --- .../public/app/__mocks__/app_dependencies.tsx | 39 ++- .../public/app/hooks/__mocks__/use_api.ts | 28 -- .../transform/public/app/hooks/use_api.ts | 9 - .../public/app/hooks/use_data_search.ts | 41 +++ .../public/app/hooks/use_index_data.ts | 262 +++++++++--------- .../components/filter_term_form.tsx | 85 +++--- .../transform/server/routes/api/transforms.ts | 28 -- 7 files changed, 261 insertions(+), 231 deletions(-) create mode 100644 x-pack/plugins/transform/public/app/hooks/use_data_search.ts diff --git a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx index 62a88a51b7b27d..3922c32df2181d 100644 --- a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx @@ -6,18 +6,21 @@ */ import { useContext } from 'react'; +import { of } from 'rxjs'; +import type { + IKibanaSearchResponse, + IKibanaSearchRequest, + ISearchGeneric, +} from '@kbn/data-plugin/public'; import type { ScopedHistory } from '@kbn/core/public'; - import { coreMock, themeServiceMock } from '@kbn/core/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { savedObjectsPluginMock } from '@kbn/saved-objects-plugin/public/mocks'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks'; - import { SharePluginStart } from '@kbn/share-plugin/public'; - import type { Storage } from '@kbn/kibana-utils-plugin/public'; import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; @@ -35,6 +38,36 @@ const dataViewsStart = dataViewPluginMocks.createStartContract(); // Replace mock to support syntax using `.then()` as used in transform code. coreStart.savedObjects.client.find = jest.fn().mockResolvedValue({ savedObjects: [] }); +// Replace mock to support tests for `use_index_data`. +dataStart.search.search = jest.fn(({ params }: IKibanaSearchRequest) => { + const hits = []; + + // simulate a cross cluster search result + // against a cluster that doesn't support fields + if (params.index.includes(':')) { + hits.push({ + _id: 'the-doc', + _index: 'the-index', + }); + } + + return of({ + rawResponse: { + hits: { + hits, + total: { + value: 0, + relation: 'eq', + }, + max_score: 0, + }, + timed_out: false, + took: 10, + _shards: { total: 1, successful: 1, failed: 0, skipped: 0 }, + }, + }); +}) as ISearchGeneric; + const appDependencies: AppDependencies = { application: coreStart.application, charts: chartPluginMock.createStartContract(), diff --git a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts index 61e6baf5c250ed..41564d393913cc 100644 --- a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - import type { IHttpFetchError } from '@kbn/core-http-browser'; import { KBN_FIELD_TYPES } from '@kbn/field-types'; @@ -135,32 +133,6 @@ const apiFactory = () => ({ ): Promise { return Promise.resolve({ messages: [], total: 0 }); }, - async esSearch(payload: any): Promise { - const hits = []; - - // simulate a cross cluster search result - // against a cluster that doesn't support fields - if (payload.index.includes(':')) { - hits.push({ - _id: 'the-doc', - _index: 'the-index', - }); - } - - return Promise.resolve({ - hits: { - hits, - total: { - value: 0, - relation: 'eq', - }, - max_score: 0, - }, - timed_out: false, - took: 10, - _shards: { total: 1, successful: 1, failed: 0, skipped: 0 }, - }); - }, async getEsIndices(): Promise { return Promise.resolve([]); diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index 3364ed58d5af63..3b39d39a3a7bc7 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -7,8 +7,6 @@ import { useMemo } from 'react'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - import type { IHttpFetchError } from '@kbn/core-http-browser'; import { KBN_FIELD_TYPES } from '@kbn/field-types'; @@ -244,13 +242,6 @@ export const useApi = () => { return e; } }, - async esSearch(payload: any): Promise { - try { - return await http.post(`${API_BASE_PATH}es_search`, { body: JSON.stringify(payload) }); - } catch (e) { - return e; - } - }, async getEsIndices(): Promise { try { return await http.get(`/api/index_management/indices`); diff --git a/x-pack/plugins/transform/public/app/hooks/use_data_search.ts b/x-pack/plugins/transform/public/app/hooks/use_data_search.ts new file mode 100644 index 00000000000000..af4bb440f9e245 --- /dev/null +++ b/x-pack/plugins/transform/public/app/hooks/use_data_search.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import { lastValueFrom } from 'rxjs'; + +import type { IKibanaSearchRequest } from '@kbn/data-plugin/common'; + +import { useAppDependencies } from '../app_dependencies'; + +export const useDataSearch = () => { + const { data } = useAppDependencies(); + + return useCallback( + async (esSearchRequestParams: IKibanaSearchRequest['params'], abortSignal?: AbortSignal) => { + try { + const { rawResponse: resp } = await lastValueFrom( + data.search.search( + { + params: esSearchRequestParams, + }, + { abortSignal } + ) + ); + + return resp; + } catch (error) { + if (error.name === 'AbortError') { + // ignore abort errors + } else { + return error; + } + } + }, + [data] + ); +}; diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts index f4d10b55620015..97005c11c36613 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts @@ -33,6 +33,7 @@ import type { StepDefineExposedState } from '../sections/create_transform/compon import { SearchItems } from './use_search_items'; import { useApi } from './use_api'; +import { useDataSearch } from './use_data_search'; export const useIndexData = ( dataView: SearchItems['dataView'], @@ -43,6 +44,7 @@ export const useIndexData = ( const indexPattern = useMemo(() => dataView.getIndexPattern(), [dataView]); const api = useApi(); + const dataSearch = useDataSearch(); const toastNotifications = useToastNotifications(); const { ml: { @@ -78,56 +80,62 @@ export const useIndexData = ( }, }; - // Fetch 500 random documents to determine populated fields. - // This is a workaround to avoid passing potentially thousands of unpopulated fields - // (for example, as part of filebeat/metricbeat/ECS based indices) - // to the data grid component which would significantly slow down the page. - const fetchDataGridSampleDocuments = async function () { - setErrorMessage(''); - setStatus(INDEX_STATUS.LOADING); - - const esSearchRequest = { - index: indexPattern, - body: { - fields: ['*'], - _source: false, - query: { - function_score: { - query: defaultQuery, - random_score: {}, + useEffect(() => { + const abortController = new AbortController(); + + // Fetch 500 random documents to determine populated fields. + // This is a workaround to avoid passing potentially thousands of unpopulated fields + // (for example, as part of filebeat/metricbeat/ECS based indices) + // to the data grid component which would significantly slow down the page. + const fetchDataGridSampleDocuments = async function () { + setErrorMessage(''); + setStatus(INDEX_STATUS.LOADING); + + const esSearchRequest = { + index: indexPattern, + body: { + fields: ['*'], + _source: false, + query: { + function_score: { + query: defaultQuery, + random_score: {}, + }, }, + size: 500, }, - size: 500, - }, - }; + }; - const resp = await api.esSearch(esSearchRequest); + const resp = await dataSearch(esSearchRequest, abortController.signal); - if (!isEsSearchResponse(resp)) { - setErrorMessage(getErrorMessage(resp)); - setStatus(INDEX_STATUS.ERROR); - return; - } + if (!isEsSearchResponse(resp)) { + setErrorMessage(getErrorMessage(resp)); + setStatus(INDEX_STATUS.ERROR); + return; + } - const isCrossClusterSearch = indexPattern.includes(':'); - const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); + const isCrossClusterSearch = indexPattern.includes(':'); + const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); - const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); + const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); - // Get all field names for each returned doc and flatten it - // to a list of unique field names used across all docs. - const allDataViewFields = getFieldsFromKibanaIndexPattern(dataView); - const populatedFields = [...new Set(docs.map(Object.keys).flat(1))] - .filter((d) => allDataViewFields.includes(d)) - .sort(); + // Get all field names for each returned doc and flatten it + // to a list of unique field names used across all docs. + const allDataViewFields = getFieldsFromKibanaIndexPattern(dataView); + const populatedFields = [...new Set(docs.map(Object.keys).flat(1))] + .filter((d) => allDataViewFields.includes(d)) + .sort(); - setCcsWarning(isCrossClusterSearch && isMissingFields); - setStatus(INDEX_STATUS.LOADED); - setDataViewFields(populatedFields); - }; + setCcsWarning(isCrossClusterSearch && isMissingFields); + setStatus(INDEX_STATUS.LOADED); + setDataViewFields(populatedFields); + }; - useEffect(() => { fetchDataGridSampleDocuments(); + + return () => { + abortController.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [timeRangeMs]); @@ -190,96 +198,62 @@ export const useIndexData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify([query, timeRangeMs])]); - const fetchDataGridData = async function () { - setErrorMessage(''); - setStatus(INDEX_STATUS.LOADING); - - const sort: EsSorting = sortingColumns.reduce((s, column) => { - s[column.id] = { order: column.direction }; - return s; - }, {} as EsSorting); - - const esSearchRequest = { - index: indexPattern, - body: { - fields: ['*'], - _source: false, - query: isDefaultQuery(query) ? defaultQuery : queryWithBaseFilterCriteria, - from: pagination.pageIndex * pagination.pageSize, - size: pagination.pageSize, - ...(Object.keys(sort).length > 0 ? { sort } : {}), - ...(isRuntimeMappings(combinedRuntimeMappings) - ? { runtime_mappings: combinedRuntimeMappings } - : {}), - }, - }; - const resp = await api.esSearch(esSearchRequest); - - if (!isEsSearchResponse(resp)) { - setErrorMessage(getErrorMessage(resp)); - setStatus(INDEX_STATUS.ERROR); - return; - } - - const isCrossClusterSearch = indexPattern.includes(':'); - const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); + useEffect(() => { + const abortController = new AbortController(); + + const fetchDataGridData = async function () { + setErrorMessage(''); + setStatus(INDEX_STATUS.LOADING); + + const sort: EsSorting = sortingColumns.reduce((s, column) => { + s[column.id] = { order: column.direction }; + return s; + }, {} as EsSorting); + + const esSearchRequest = { + index: indexPattern, + body: { + fields: ['*'], + _source: false, + query: isDefaultQuery(query) ? defaultQuery : queryWithBaseFilterCriteria, + from: pagination.pageIndex * pagination.pageSize, + size: pagination.pageSize, + ...(Object.keys(sort).length > 0 ? { sort } : {}), + ...(isRuntimeMappings(combinedRuntimeMappings) + ? { runtime_mappings: combinedRuntimeMappings } + : {}), + }, + }; + const resp = await dataSearch(esSearchRequest, abortController.signal); - const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); + if (!isEsSearchResponse(resp)) { + setErrorMessage(getErrorMessage(resp)); + setStatus(INDEX_STATUS.ERROR); + return; + } - setCcsWarning(isCrossClusterSearch && isMissingFields); - setRowCountInfo({ - rowCount: typeof resp.hits.total === 'number' ? resp.hits.total : resp.hits.total!.value, - rowCountRelation: - typeof resp.hits.total === 'number' - ? ('eq' as estypes.SearchTotalHitsRelation) - : resp.hits.total!.relation, - }); - setTableItems(docs); - setStatus(INDEX_STATUS.LOADED); - }; + const isCrossClusterSearch = indexPattern.includes(':'); + const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); - const fetchColumnChartsData = async function () { - const allDataViewFieldNames = new Set(dataView.fields.map((f) => f.name)); - const columnChartsData = await api.getHistogramsForFields( - indexPattern, - columns - .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) - .map((cT) => { - // If a column field name has a corresponding keyword field, - // fetch the keyword field instead to be able to do aggregations. - const fieldName = cT.id; - return hasKeywordDuplicate(fieldName, allDataViewFieldNames) - ? { - fieldName: `${fieldName}.keyword`, - type: getFieldType(undefined), - } - : { - fieldName, - type: getFieldType(cT.schema), - }; - }), - isDefaultQuery(query) ? defaultQuery : queryWithBaseFilterCriteria, - combinedRuntimeMappings - ); - - if (!isFieldHistogramsResponseSchema(columnChartsData)) { - showDataGridColumnChartErrorMessageToast(columnChartsData, toastNotifications); - return; - } + const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); - setColumnCharts( - // revert field names with `.keyword` used to do aggregations to their original column name - columnChartsData.map((d) => ({ - ...d, - ...(isKeywordDuplicate(d.id, allDataViewFieldNames) - ? { id: removeKeywordPostfix(d.id) } - : {}), - })) - ); - }; + setCcsWarning(isCrossClusterSearch && isMissingFields); + setRowCountInfo({ + rowCount: typeof resp.hits.total === 'number' ? resp.hits.total : resp.hits.total!.value, + rowCountRelation: + typeof resp.hits.total === 'number' + ? ('eq' as estypes.SearchTotalHitsRelation) + : resp.hits.total!.relation, + }); + setTableItems(docs); + setStatus(INDEX_STATUS.LOADED); + }; - useEffect(() => { fetchDataGridData(); + + return () => { + abortController.abort(); + }; // custom comparison // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -296,6 +270,46 @@ export const useIndexData = ( ]); useEffect(() => { + const fetchColumnChartsData = async function () { + const allDataViewFieldNames = new Set(dataView.fields.map((f) => f.name)); + const columnChartsData = await api.getHistogramsForFields( + indexPattern, + columns + .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + .map((cT) => { + // If a column field name has a corresponding keyword field, + // fetch the keyword field instead to be able to do aggregations. + const fieldName = cT.id; + return hasKeywordDuplicate(fieldName, allDataViewFieldNames) + ? { + fieldName: `${fieldName}.keyword`, + type: getFieldType(undefined), + } + : { + fieldName, + type: getFieldType(cT.schema), + }; + }), + isDefaultQuery(query) ? defaultQuery : queryWithBaseFilterCriteria, + combinedRuntimeMappings + ); + + if (!isFieldHistogramsResponseSchema(columnChartsData)) { + showDataGridColumnChartErrorMessageToast(columnChartsData, toastNotifications); + return; + } + + setColumnCharts( + // revert field names with `.keyword` used to do aggregations to their original column name + columnChartsData.map((d) => ({ + ...d, + ...(isKeywordDuplicate(d.id, allDataViewFieldNames) + ? { id: removeKeywordPostfix(d.id) } + : {}), + })) + ); + }; + if (chartsVisible) { fetchColumnChartsData(); } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx index 11f9dadbb359c6..6749786865083e 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx @@ -5,22 +5,27 @@ * 2.0. */ +import { debounce } from 'lodash'; import React, { useCallback, useContext, useEffect, useState } from 'react'; +import useUpdateEffect from 'react-use/lib/useUpdateEffect'; + import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; + import { FormattedMessage } from '@kbn/i18n-react'; -import { debounce } from 'lodash'; -import useUpdateEffect from 'react-use/lib/useUpdateEffect'; import { i18n } from '@kbn/i18n'; + +import { useDataSearch } from '../../../../../../../hooks/use_data_search'; import { isEsSearchResponseWithAggregations, isMultiBucketAggregate, } from '../../../../../../../../../common/api_schemas/type_guards'; -import { useApi } from '../../../../../../../hooks'; import { CreateTransformWizardContext } from '../../../../wizard/wizard'; -import { FilterAggConfigTerm } from '../types'; import { useToastNotifications } from '../../../../../../../app_dependencies'; +import { FilterAggConfigTerm } from '../types'; + /** * Form component for the term filter aggregation. */ @@ -29,16 +34,39 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm onChange, selectedField, }) => { - const api = useApi(); const { dataView, runtimeMappings } = useContext(CreateTransformWizardContext); + const dataSearch = useDataSearch(); const toastNotifications = useToastNotifications(); const [options, setOptions] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [searchValue, setSearchValue] = useState(''); + + const onSearchChange = (newSearchValue: string) => { + setSearchValue(newSearchValue); + }; + + const updateConfig = useCallback( + (update) => { + onChange({ + config: { + ...config, + ...update, + }, + }); + }, + [config, onChange] + ); + + useEffect(() => { + const abortController = new AbortController(); + + const fetchOptions = debounce(async () => { + if (selectedField === undefined) return; + + setIsLoading(true); + setOptions([]); - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const fetchOptions = useCallback( - debounce(async (searchValue: string) => { const esSearchRequest = { index: dataView!.title, body: { @@ -62,7 +90,7 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm }, }; - const response = await api.esSearch(esSearchRequest); + const response = await dataSearch(esSearchRequest, abortController.signal); setIsLoading(false); @@ -88,42 +116,21 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm .buckets as estypes.AggregationsSignificantLongTermsBucket[] ).map((value) => ({ label: value.key + '' })) ); - }, 600), - [selectedField] - ); - - const onSearchChange = useCallback( - async (searchValue) => { - if (selectedField === undefined) return; + }, 600); - setIsLoading(true); - setOptions([]); + fetchOptions(); - await fetchOptions(searchValue); - }, - [fetchOptions, selectedField] - ); - - const updateConfig = useCallback( - (update) => { - onChange({ - config: { - ...config, - ...update, - }, - }); - }, - [config, onChange] - ); - - useEffect(() => { - // Simulate initial load. - onSearchChange(''); return () => { // make sure the ongoing request is canceled fetchOptions.cancel(); + abortController.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [selectedField]); + + useEffect(() => { + // Simulate initial load. + onSearchChange(''); }, []); useUpdateEffect(() => { diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index d19d9eb1a2963f..36459711a7b34e 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { @@ -529,33 +528,6 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { ) ); - /** - * @apiGroup Transforms - * - * @api {post} /api/transform/es_search Transform ES Search Proxy - * @apiName PostTransformEsSearchProxy - * @apiDescription ES Search Proxy - * - * @apiSchema (body) any - */ - router.post( - { - path: addBasePath('es_search'), - validate: { - body: schema.maybe(schema.any()), - }, - }, - license.guardApiRoute(async (ctx, req, res) => { - try { - const esClient = (await ctx.core).elasticsearch.client; - const body = await esClient.asCurrentUser.search(req.body, { maxRetries: 0 }); - return res.ok({ body }); - } catch (e) { - return res.customError(wrapError(wrapEsError(e))); - } - }) - ); - registerTransformsAuditMessagesRoutes(routeDependencies); registerTransformNodesRoutes(routeDependencies); } From bd01a35555c148bea44abf0181789feb4fed72b7 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Tue, 25 Apr 2023 15:27:55 +0100 Subject: [PATCH 06/13] [ML] Adding ignore unavailable indices option to anomaly detection job wizards (#155527) Adds a switch to set the `indices_options` property `ignore_unavailable` to true. If switched off, the property is removed from the datafeed config. **Single metric wizard (all non-advanced wizards)** ![image](https://user-images.githubusercontent.com/22172091/233674155-4ef74d94-9938-4228-8b67-2eff3eff89f2.png) **Advanced wizard** ![image](https://user-images.githubusercontent.com/22172091/233674357-bcd3a83f-6476-4cbf-8064-f3e9f3e4b495.png) --------- Co-authored-by: Dima Arnautov --- .../new_job/common/job_creator/job_creator.ts | 30 +++++++++++-- .../advanced_section/advanced_section.tsx | 43 ++++++++++++++++-- .../annotations/annotations_switch.tsx | 3 +- .../ignore_unavailable/description.tsx | 42 ++++++++++++++++++ .../ignore_unavailable_switch.tsx | 44 +++++++++++++++++++ .../components/ignore_unavailable/index.ts | 8 ++++ .../model_plot/model_plot_switch.tsx | 3 +- 7 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/description.tsx create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/ignore_unavailable_switch.tsx create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/index.ts diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts index f445afe634b90d..2d282563367573 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts @@ -20,7 +20,7 @@ import { mlOnlyAggregations, } from '../../../../../../common/constants/aggregation_types'; import { getQueryFromSavedSearchObject } from '../../../../util/index_utils'; -import { +import type { Job, Datafeed, Detector, @@ -29,11 +29,11 @@ import { BucketSpan, CustomSettings, } from '../../../../../../common/types/anomaly_detection_jobs'; -import { Aggregation, Field, RuntimeMappings } from '../../../../../../common/types/fields'; +import type { Aggregation, Field, RuntimeMappings } from '../../../../../../common/types/fields'; import { combineFieldsAndAggs } from '../../../../../../common/util/fields_utils'; import { createEmptyJob, createEmptyDatafeed } from './util/default_configs'; import { mlJobService } from '../../../../services/job_service'; -import { JobRunner, ProgressSubscriber } from '../job_runner'; +import { JobRunner, type ProgressSubscriber } from '../job_runner'; import { JOB_TYPE, CREATED_BY_LABEL, @@ -42,7 +42,7 @@ import { import { collectAggs } from './util/general'; import { filterRuntimeMappings } from './util/filter_runtime_mappings'; import { parseInterval } from '../../../../../../common/util/parse_interval'; -import { Calendar } from '../../../../../../common/types/calendars'; +import type { Calendar } from '../../../../../../common/types/calendars'; import { mlCalendarService } from '../../../../services/calendar_service'; import { getDatafeedAggregations } from '../../../../../../common/util/datafeed_utils'; import { getFirstKeyInObject } from '../../../../../../common/util/object_utils'; @@ -542,6 +542,28 @@ export class JobCreator { this._datafeed_config.indices = indics; } + public get ignoreUnavailable(): boolean { + return !!this._datafeed_config.indices_options?.ignore_unavailable; + } + + public set ignoreUnavailable(ignore: boolean) { + if (ignore === true) { + if (this._datafeed_config.indices_options === undefined) { + this._datafeed_config.indices_options = {}; + } + this._datafeed_config.indices_options.ignore_unavailable = true; + } else { + if (this._datafeed_config.indices_options !== undefined) { + delete this._datafeed_config.indices_options.ignore_unavailable; + + // if no other properties are set, remove indices_options + if (Object.keys(this._datafeed_config.indices_options).length === 0) { + delete this._datafeed_config.indices_options; + } + } + } + } + public get scriptFields(): Field[] { return this._scriptFields; } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/advanced_section.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/advanced_section.tsx index 6774db9225fd44..e0ad13e1d1c006 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/advanced_section.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/advanced_section.tsx @@ -20,6 +20,7 @@ import { DedicatedIndexSwitch } from './components/dedicated_index'; import { ModelMemoryLimitInput } from '../../../common/model_memory_limit'; import { JobCreatorContext } from '../../../job_creator_context'; import { JOB_TYPE } from '../../../../../../../../../common/constants/new_job'; +import { IgnoreUnavailableSwitch } from './components/ignore_unavailable'; const buttonContent = i18n.translate( 'xpack.ml.newJob.wizard.jobDetailsStep.advancedSectionButton', @@ -43,12 +44,22 @@ export const AdvancedSection: FC = ({ advancedExpanded, setAdvancedExpand - + + + + + + + + + + + ); } @@ -71,13 +82,39 @@ export const AdvancedSection: FC = ({ advancedExpanded, setAdvancedExpand > - -
+ + + + + + + + + + + + + + + + + + + + ); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/annotations/annotations_switch.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/annotations/annotations_switch.tsx index 12294a2e3fed49..154d0785e244e1 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/annotations/annotations_switch.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/annotations/annotations_switch.tsx @@ -7,7 +7,7 @@ import React, { FC, useState, useContext, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiCallOut, EuiSpacer, EuiSwitch } from '@elastic/eui'; +import { EuiCallOut, EuiSwitch } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { JobCreatorContext } from '../../../../../job_creator_context'; import { Description } from './description'; @@ -62,7 +62,6 @@ export const AnnotationsSwitch: FC = () => { iconType="help" /> )} - ); }; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/description.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/description.tsx new file mode 100644 index 00000000000000..5e408560842464 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/description.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiCode, EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +export const Description: FC = memo(({ children }) => { + const title = i18n.translate( + 'xpack.ml.newJob.wizard.jobDetailsStep.advancedSection.ignoreUnavailable.title', + { + defaultMessage: 'Ignore unavailable indices', + } + ); + return ( + {title}} + description={ + + ignore_unavailable + + ), + }} + /> + } + > + + <>{children} + + + ); +}); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/ignore_unavailable_switch.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/ignore_unavailable_switch.tsx new file mode 100644 index 00000000000000..aa6be80329c764 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/ignore_unavailable_switch.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useState, useContext, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSwitch } from '@elastic/eui'; +import { JobCreatorContext } from '../../../../../job_creator_context'; +import { Description } from './description'; + +export const IgnoreUnavailableSwitch: FC = () => { + const { jobCreator, jobCreatorUpdate } = useContext(JobCreatorContext); + const [ignoreUnavailable, setIgnoreUnavailable] = useState(jobCreator.ignoreUnavailable); + + useEffect(() => { + jobCreator.ignoreUnavailable = ignoreUnavailable; + jobCreatorUpdate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ignoreUnavailable]); + + function toggleIgnoreUnavailable() { + setIgnoreUnavailable(!ignoreUnavailable); + } + + return ( + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/index.ts b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/index.ts new file mode 100644 index 00000000000000..997ee4f81a1754 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { IgnoreUnavailableSwitch } from './ignore_unavailable_switch'; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx index 631922a388faac..167335bacab77e 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx @@ -7,7 +7,7 @@ import React, { FC, useState, useContext, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiSpacer, EuiSwitch } from '@elastic/eui'; +import { EuiSwitch } from '@elastic/eui'; import { JobCreatorContext } from '../../../../../job_creator_context'; import { Description } from './description'; import { MMLCallout } from '../mml_callout'; @@ -58,7 +58,6 @@ export const ModelPlotSwitch: FC = () => { />
- ); }; From 7c69a34b7bd557796390b18d4c77c78ce368ed5d Mon Sep 17 00:00:00 2001 From: Saarika Bhasi <55930906+saarikabhasi@users.noreply.github.com> Date: Tue, 25 Apr 2023 10:28:28 -0400 Subject: [PATCH 07/13] [Search Application] [Preview] Add configuration button with Nav items (#155568) ## Summary * Add configuration Button and comment out changes for View this API call - see [thread](https://elastic.slack.com/archives/C02U50QNEAG/p1682089775653529?thread_ts=1681758090.657279&cid=C02U50QNEAG) ### Screen Recording https://user-images.githubusercontent.com/55930906/234107309-40542d5f-253e-4e5b-8c2c-418530446e72.mov --- .../engine_search_preview.tsx | 246 ++++++++++++++++-- 1 file changed, 222 insertions(+), 24 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_search_preview/engine_search_preview.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_search_preview/engine_search_preview.tsx index 2dfaa672459e8c..5cd4895c3aa351 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_search_preview/engine_search_preview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_search_preview/engine_search_preview.tsx @@ -7,9 +7,23 @@ import React, { useState, useMemo } from 'react'; -import { useValues } from 'kea'; +import { useActions, useValues } from 'kea'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIcon, + EuiLink, + EuiPanel, + EuiPopover, + EuiSpacer, + EuiTextColor, + EuiTitle, +} from '@elastic/eui'; import { PagingInfo, Results, @@ -24,18 +38,27 @@ import EnginesAPIConnector, { SearchResponse, } from '@elastic/search-ui-engines-connector'; import { HttpSetup } from '@kbn/core-http-browser'; + import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { docLinks } from '../../../../shared/doc_links'; +import { generateEncodedPath } from '../../../../shared/encode_path_params'; import { HttpLogic } from '../../../../shared/http'; -import { EngineViewTabs } from '../../../routes'; +import { KibanaLogic } from '../../../../shared/kibana'; +import { TelemetryLogic } from '../../../../shared/telemetry'; +import { + EngineViewTabs, + SearchApplicationConnectTabs, + SearchApplicationContentTabs, + SEARCH_APPLICATION_CONNECT_PATH, + SEARCH_APPLICATION_CONTENT_PATH, +} from '../../../routes'; import { EnterpriseSearchEnginesPageTemplate } from '../../layout/engines_page_template'; import { EngineIndicesLogic } from '../engine_indices_logic'; import { EngineViewLogic } from '../engine_view_logic'; -import { APICallFlyout, APICallData } from './api_call_flyout'; import { DocumentProvider } from './document_context'; import { DocumentFlyout } from './document_flyout'; import { EngineSearchPreviewLogic } from './engine_search_preview_logic'; @@ -53,8 +76,7 @@ import { class InternalEngineTransporter implements Transporter { constructor( private http: HttpSetup, - private engineName: string, - private setLastAPICall: (apiCallData: APICallData) => void + private engineName: string // uncomment and add setLastAPICall to constructor when view this API call is needed // private setLastAPICall?: (apiCallData: APICallData) => void ) {} async performRequest(request: SearchRequest) { @@ -64,7 +86,7 @@ class InternalEngineTransporter implements Transporter { body: JSON.stringify(request), }); - this.setLastAPICall({ request, response }); + // this.setLastAPICall({ request, response }); Uncomment when view this API call is needed const withUniqueIds = { ...response, @@ -87,20 +109,184 @@ class InternalEngineTransporter implements Transporter { } } -const pageTitle = i18n.translate('xpack.enterpriseSearch.content.engine.searchPreview.pageTitle', { - defaultMessage: 'Search Preview', -}); +interface ConfigurationPopOverProps { + engineName: string; + setCloseConfiguration: () => void; + showConfiguration: boolean; +} + +const ConfigurationPopover: React.FC = ({ + engineName, + showConfiguration, + setCloseConfiguration, +}) => { + const { navigateToUrl } = useValues(KibanaLogic); + const { engineData } = useValues(EngineViewLogic); + const { openDeleteEngineModal } = useActions(EngineViewLogic); + const { sendEnterpriseSearchTelemetry } = useActions(TelemetryLogic); + return ( + <> + + {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.buttonTitle', + { + defaultMessage: 'Configuration', + } + )} + + } + > + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.contentTitle', + { + defaultMessage: 'Content', + } + )} +

+
+
+ + + + navigateToUrl( + generateEncodedPath(SEARCH_APPLICATION_CONTENT_PATH, { + contentTabId: SearchApplicationContentTabs.INDICES, + engineName, + }) + ) + } + > + {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.content.Indices', + { + defaultMessage: 'Indices', + } + )} + + + navigateToUrl( + generateEncodedPath(SEARCH_APPLICATION_CONTENT_PATH, { + contentTabId: SearchApplicationContentTabs.SCHEMA, + engineName, + }) + ) + } + > + {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.content.Schema', + { + defaultMessage: 'Schema', + } + )} + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.connectTitle', + { + defaultMessage: 'Connect', + } + )} +

+
+
+ + + navigateToUrl( + generateEncodedPath(SEARCH_APPLICATION_CONNECT_PATH, { + connectTabId: SearchApplicationConnectTabs.API, + engineName, + }) + ) + } + > + {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.connect.Api', + { + defaultMessage: 'API', + } + )} + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.settingsTitle', + { + defaultMessage: 'Settings', + } + )} +

+
+
+ + } + onClick={() => { + if (engineData) { + openDeleteEngineModal(); + sendEnterpriseSearchTelemetry({ + action: 'clicked', + metric: 'entSearchContent-engines-engineView-deleteEngine', + }); + } + }} + > + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.settings.delete', + { + defaultMessage: 'Delete this app', + } + )} +

+
+
+
+
+ + ); +}; export const EngineSearchPreview: React.FC = () => { const { http } = useValues(HttpLogic); - const [showAPICallFlyout, setShowAPICallFlyout] = useState(false); - const [lastAPICall, setLastAPICall] = useState(null); + // const [showAPICallFlyout, setShowAPICallFlyout] = useState(false); Uncomment when view this API call is needed + const [showConfigurationPopover, setShowConfigurationPopover] = useState(false); + // const [lastAPICall, setLastAPICall] = useState(null); Uncomment when view this API call is needed const { engineName, isLoadingEngine } = useValues(EngineViewLogic); const { resultFields, searchableFields, sortableFields } = useValues(EngineSearchPreviewLogic); const { engineData } = useValues(EngineIndicesLogic); const config: SearchDriverOptions = useMemo(() => { - const transporter = new InternalEngineTransporter(http, engineName, setLastAPICall); + const transporter = new InternalEngineTransporter(http, engineName); const connector = new EnginesAPIConnector(transporter); return { @@ -112,27 +298,35 @@ export const EngineSearchPreview: React.FC = () => { search_fields: searchableFields, }, }; - }, [http, engineName, setLastAPICall, resultFields, searchableFields]); + }, [http, engineName, resultFields, searchableFields]); if (!engineData) return null; return ( + ), rightSideItems: [ <> - setShowAPICallFlyout(true)} - isLoading={lastAPICall == null} - > - View this API call - + setShowConfigurationPopover(!showConfigurationPopover)} + /> , ], }} @@ -167,6 +361,9 @@ export const EngineSearchPreview: React.FC = () => { + {/* + Uncomment when view this API call needed + {showAPICallFlyout && lastAPICall && ( setShowAPICallFlyout(false)} @@ -174,6 +371,7 @@ export const EngineSearchPreview: React.FC = () => { engineName={engineName} /> )} + */} ); From 3864554b3661fd65106e0d9c5c4d50cfa167dd68 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 25 Apr 2023 16:30:11 +0200 Subject: [PATCH 08/13] [Synthetics] Monitor overview item display median duration (#155694) ## Summary Display median duration instead of avg on monitor overview item image --- .../monitor_summary/duration_panel.tsx | 8 +-- .../monitor_summary/duration_sparklines.tsx | 4 +- .../overview/overview/metric_item.tsx | 51 +++++++++++++++---- .../overview/overview/overview_grid.test.tsx | 22 +++++--- .../overview/overview/overview_grid_item.tsx | 12 ++++- .../hooks/use_last_50_duration_chart.test.ts | 10 +++- .../hooks/use_last_50_duration_chart.ts | 29 ++++++++--- .../synthetics/utils/formatting/format.ts | 15 +++++- .../translations/translations/fr-FR.json | 3 +- .../translations/translations/ja-JP.json | 3 +- .../translations/translations/zh-CN.json | 3 +- 11 files changed, 121 insertions(+), 39 deletions(-) diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx index 85ab6773033bef..7ca1d1e003cc44 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx @@ -42,7 +42,7 @@ export const DurationPanel = (props: DurationPanelProps) => { attributes={[ { time: props, - name: AVG_DURATION_LABEL, + name: MEDIAN_DURATION_LABEL, dataType: 'synthetics', selectedMetricField: 'monitor_duration', reportDefinitions: { @@ -55,9 +55,9 @@ export const DurationPanel = (props: DurationPanelProps) => { ); }; -export const AVG_DURATION_LABEL = i18n.translate( - 'xpack.synthetics.monitorDetails.summary.avgDuration', +export const MEDIAN_DURATION_LABEL = i18n.translate( + 'xpack.synthetics.monitorDetails.summary.medianDuration', { - defaultMessage: 'Avg. duration', + defaultMessage: 'Median duration', } ); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_sparklines.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_sparklines.tsx index 1c1370d4da3ab2..5851d1c47cdf50 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_sparklines.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_sparklines.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { ReportTypes } from '@kbn/exploratory-view-plugin/public'; import { useTheme } from '@kbn/observability-plugin/public'; -import { AVG_DURATION_LABEL } from './duration_panel'; +import { MEDIAN_DURATION_LABEL } from './duration_panel'; import { useMonitorQueryId } from '../hooks/use_monitor_query_id'; import { ClientPluginsStart } from '../../../../../plugin'; import { useSelectedLocation } from '../hooks/use_selected_location'; @@ -47,7 +47,7 @@ export const DurationSparklines = (props: DurationSparklinesProps) => { { seriesType: 'area', time: props, - name: AVG_DURATION_LABEL, + name: MEDIAN_DURATION_LABEL, dataType: 'synthetics', selectedMetricField: 'monitor.duration.us', reportDefinitions: { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx index 88cfaf75e061c4..369010408917c0 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; import { Chart, Settings, Metric, MetricTrendShape } from '@elastic/charts'; -import { EuiPanel } from '@elastic/eui'; +import { EuiPanel, EuiIconTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { DARK_THEME } from '@elastic/charts'; import { useTheme } from '@kbn/observability-plugin/public'; import { useDispatch, useSelector } from 'react-redux'; @@ -46,13 +46,19 @@ export const getColor = ( export const MetricItem = ({ monitor, - averageDuration, + medianDuration, + maxDuration, + minDuration, + avgDuration, data, onClick, }: { monitor: MonitorOverviewItem; data: Array<{ x: number; y: number }>; - averageDuration: number; + medianDuration: number; + avgDuration: number; + minDuration: number; + maxDuration: number; onClick: (params: { id: string; configId: string; location: string; locationId: string }) => void; }) => { const [isMouseOver, setIsMouseOver] = useState(false); @@ -119,15 +125,42 @@ export const MetricItem = ({ { title: monitor.name, subtitle: locationName, - value: averageDuration, + value: medianDuration, trendShape: MetricTrendShape.Area, trend: data, extra: ( - - {i18n.translate('xpack.synthetics.overview.duration.label', { - defaultMessage: 'Duration Avg.', - })} - + + + {i18n.translate('xpack.synthetics.overview.duration.label', { + defaultMessage: 'Duration', + })} + + + + + ), valueFormatter: (d: number) => formatDuration(d), color: getColor(theme, monitor.isEnabled, status), diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx index 8238d7b26b62a7..ed470f6f24bced 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx @@ -64,9 +64,14 @@ describe('Overview Grid', () => { const perPage = 20; it('renders correctly', async () => { - jest - .spyOn(hooks, 'useLast50DurationChart') - .mockReturnValue({ data: getMockChart(), averageDuration: 30000, loading: false }); + jest.spyOn(hooks, 'useLast50DurationChart').mockReturnValue({ + data: getMockChart(), + avgDuration: 30000, + minDuration: 0, + maxDuration: 50000, + medianDuration: 15000, + loading: false, + }); const { getByText, getAllByTestId, queryByText } = render(, { state: { @@ -124,9 +129,14 @@ describe('Overview Grid', () => { }); it('displays showing all monitors label when reaching the end of the list', async () => { - jest - .spyOn(hooks, 'useLast50DurationChart') - .mockReturnValue({ data: getMockChart(), averageDuration: 30000, loading: false }); + jest.spyOn(hooks, 'useLast50DurationChart').mockReturnValue({ + data: getMockChart(), + avgDuration: 30000, + minDuration: 0, + maxDuration: 50000, + medianDuration: 15000, + loading: false, + }); const { getByText } = render(, { state: { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx index de153cf01eca7c..952a48d4247336 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx @@ -32,12 +32,20 @@ export const OverviewGridItem = ({ const { timestamp } = useStatusByLocationOverview(monitor.configId, locationName); - const { data, averageDuration } = useLast50DurationChart({ + const { data, medianDuration, maxDuration, avgDuration, minDuration } = useLast50DurationChart({ locationId: monitor.location?.id, monitorId: monitor.id, timestamp, }); return ( - + ); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.test.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.test.ts index bb2e74712322f8..47cb97793bab1f 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.test.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.test.ts @@ -33,7 +33,10 @@ describe('useLast50DurationChart', () => { { wrapper: WrappedHelper } ); expect(result.current).toEqual({ - averageDuration: 4.5, + medianDuration: 5, + maxDuration: 9, + minDuration: 0, + avgDuration: 4.5, data: [ { x: 0, @@ -132,7 +135,10 @@ describe('useLast50DurationChart', () => { ]; expect(result.current).toEqual({ - averageDuration: data.reduce((acc, datum) => (acc += datum.y), 0) / 9, + medianDuration: [...data].sort((a, b) => a.y - b.y)[Math.floor(data.length / 2)].y, + maxDuration: 9, + minDuration: 0, + avgDuration: 4.4, data, loading: false, }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts index 78e4cc6cecbbf7..8cb7d524635c72 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts @@ -28,20 +28,23 @@ export function useLast50DurationChart({ size: 50, timestamp, }); - const { data, averageDuration } = useMemo(() => { + const { data, median, min, max, avg } = useMemo(() => { if (loading) { return { data: [], - averageDuration: 0, + median: 0, + avg: 0, + min: 0, + max: 0, }; } - let totalDuration = 0; + + // calculate min, max, average duration and median const coords = hits .reverse() // results are returned in desc order by timestamp. Reverse to ensure the data is in asc order by timestamp .map((hit, index) => { const duration = hit?.['monitor.duration.us']?.[0]; - totalDuration += duration || 0; if (duration === undefined) { return null; } @@ -52,18 +55,30 @@ export function useLast50DurationChart({ }) .filter((item) => item !== null); + const sortedByDuration = [...hits].sort( + (a, b) => (a?.['monitor.duration.us']?.[0] || 0) - (b?.['monitor.duration.us']?.[0] || 0) + ); + return { data: coords as Array<{ x: number; y: number }>, - averageDuration: totalDuration / coords.length, + median: sortedByDuration[Math.floor(hits.length / 2)]?.['monitor.duration.us']?.[0] || 0, + avg: + sortedByDuration.reduce((acc, curr) => acc + (curr?.['monitor.duration.us']?.[0] || 0), 0) / + hits.length, + min: sortedByDuration[0]?.['monitor.duration.us']?.[0] || 0, + max: sortedByDuration[sortedByDuration.length - 1]?.['monitor.duration.us']?.[0] || 0, }; }, [hits, loading]); return useMemo( () => ({ data, - averageDuration, + medianDuration: median, + avgDuration: avg, + minDuration: min, + maxDuration: max, loading, }), - [loading, data, averageDuration] + [data, median, avg, min, max, loading] ); } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/formatting/format.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/formatting/format.ts index 5dab17f55ad68b..433d4a9c8cfec2 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/utils/formatting/format.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/formatting/format.ts @@ -25,8 +25,14 @@ export const microsToMillis = (microseconds: number | null): number | null => { return Math.floor(microseconds / NUM_MICROSECONDS_IN_MILLISECOND); }; -export const formatDuration = (durationMicros: number) => { +export const formatDuration = (durationMicros: number, { noSpace }: { noSpace?: true } = {}) => { if (durationMicros < MILLIS_LIMIT) { + if (noSpace) { + return i18n.translate('xpack.synthetics.overview.durationMsFormattingNoSpace', { + values: { millis: microsToMillis(durationMicros) }, + defaultMessage: '{millis}ms', + }); + } return i18n.translate('xpack.synthetics.overview.durationMsFormatting', { values: { millis: microsToMillis(durationMicros) }, defaultMessage: '{millis} ms', @@ -34,6 +40,13 @@ export const formatDuration = (durationMicros: number) => { } const seconds = (durationMicros / ONE_SECOND_AS_MICROS).toFixed(0); + if (noSpace) { + return i18n.translate('xpack.synthetics.overview.durationSecondsFormattingNoSpace', { + values: { seconds }, + defaultMessage: '{seconds}s', + }); + } + return i18n.translate('xpack.synthetics.overview.durationSecondsFormatting', { values: { seconds }, defaultMessage: '{seconds} s', diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index d3a86b78b95515..191f6310ace50b 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -34806,7 +34806,6 @@ "xpack.synthetics.monitorDetails.statusBar.pingType.icmp": "ICMP", "xpack.synthetics.monitorDetails.statusBar.pingType.tcp": "TCP", "xpack.synthetics.monitorDetails.summary.availability": "Disponibilité", - "xpack.synthetics.monitorDetails.summary.avgDuration": "Durée moy.", "xpack.synthetics.monitorDetails.summary.brushArea": "Brosser une zone pour une plus haute fidélité", "xpack.synthetics.monitorDetails.summary.complete": "Terminé", "xpack.synthetics.monitorDetails.summary.duration": "Durée", @@ -37947,4 +37946,4 @@ "xpack.painlessLab.title": "Painless Lab", "xpack.painlessLab.walkthroughButtonLabel": "Présentation" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 723902ad29739c..48521b3e3b558e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -34785,7 +34785,6 @@ "xpack.synthetics.monitorDetails.statusBar.pingType.icmp": "ICMP", "xpack.synthetics.monitorDetails.statusBar.pingType.tcp": "TCP", "xpack.synthetics.monitorDetails.summary.availability": "可用性", - "xpack.synthetics.monitorDetails.summary.avgDuration": "平均期間", "xpack.synthetics.monitorDetails.summary.brushArea": "信頼度を高めるためにエリアを精査", "xpack.synthetics.monitorDetails.summary.complete": "完了", "xpack.synthetics.monitorDetails.summary.duration": "期間", @@ -37915,4 +37914,4 @@ "xpack.painlessLab.title": "Painless Lab", "xpack.painlessLab.walkthroughButtonLabel": "実地検証" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 469bb9a2cf748b..d3b2c66d7d642f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -34802,7 +34802,6 @@ "xpack.synthetics.monitorDetails.statusBar.pingType.icmp": "ICMP", "xpack.synthetics.monitorDetails.statusBar.pingType.tcp": "TCP", "xpack.synthetics.monitorDetails.summary.availability": "可用性", - "xpack.synthetics.monitorDetails.summary.avgDuration": "平均持续时间", "xpack.synthetics.monitorDetails.summary.brushArea": "轻刷某个区域以提高保真度", "xpack.synthetics.monitorDetails.summary.complete": "已完成", "xpack.synthetics.monitorDetails.summary.duration": "持续时间", @@ -37943,4 +37942,4 @@ "xpack.painlessLab.title": "Painless 实验室", "xpack.painlessLab.walkthroughButtonLabel": "指导" } -} \ No newline at end of file +} From b6a91f318ecc8398a166803fcef039b7e8424c1c Mon Sep 17 00:00:00 2001 From: Miriam <31922082+MiriamAparicio@users.noreply.github.com> Date: Tue, 25 Apr 2023 15:43:21 +0100 Subject: [PATCH 09/13] [APM] Add table tabs showing summary of metrics (#153044) Closes https://github.com/elastic/kibana/issues/146877 Notes: - Crash rate is calculated per sessions, the same session ID is kept after a crash, so a session can have more than one crash. - App number of launches is not available - Error rate stays out for now - Http requests, `host.os.version` and `service.version` is only available on transactions, metrics and errors. Not spans for now, there are two issues opened to fix this for the apm mobile agents team - Instead of the View Load (not available), we show Throughput - The filters (+ -) will be added in a follow-up PR Pending: - [x] API tests - [x] e2e tests https://user-images.githubusercontent.com/31922082/234267965-e5e1e411-87c6-40b8-9e94-31d792f9d806.mov --------- Co-authored-by: Yngrid Coello --- .../src/lib/apm/apm_fields.ts | 2 + .../src/lib/apm/mobile_device.ts | 22 +- .../src/scenarios/mobile.ts | 88 ++++--- x-pack/plugins/apm/common/data_source.ts | 3 +- x-pack/plugins/apm/common/document_type.ts | 1 + .../__snapshots__/es_fields.test.ts.snap | 6 + x-pack/plugins/apm/common/es_fields/apm.ts | 1 + .../read_only_user/mobile/generate_data.ts | 221 ++++++++++++++++++ .../mobile/mobile_transactions.cy.ts | 68 ++++++ .../app/mobile/transaction_overview/index.tsx | 12 +- .../app_version_tab.tsx | 61 +++++ .../transaction_overview_tabs/devices_tab.tsx | 58 +++++ .../transaction_overview_tabs/index.tsx | 77 ++++++ .../os_version_tab.tsx | 61 +++++ .../stats_list/get_columns.tsx | 148 ++++++++++++ .../stats_list/index.tsx | 67 ++++++ .../transactions_tab.tsx | 40 ++++ .../use_mobile_statistics_fetcher.ts | 120 ++++++++++ .../routing/mobile_service_detail/index.tsx | 1 + .../shared/transactions_table/index.tsx | 53 +++-- .../helpers/create_es_client/document_type.ts | 4 + ...get_mobile_detailed_statistics_by_field.ts | 214 +++++++++++++++++ .../get_mobile_main_statistics_by_field.ts | 186 +++++++++++++++ .../plugins/apm/server/routes/mobile/route.ts | 90 ++++++- .../tests/mobile/generate_mobile_data.ts | 11 +- ...obile_detailed_statistics_by_field.spec.ts | 132 +++++++++++ .../mobile_main_statistics_by_field.spec.ts | 143 ++++++++++++ 27 files changed, 1826 insertions(+), 64 deletions(-) create mode 100644 x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/generate_data.ts create mode 100644 x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/mobile_transactions.cy.ts create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/app_version_tab.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/devices_tab.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/index.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/os_version_tab.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/get_columns.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/index.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/transactions_tab.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/use_mobile_statistics_fetcher.ts create mode 100644 x-pack/plugins/apm/server/routes/mobile/get_mobile_detailed_statistics_by_field.ts create mode 100644 x-pack/plugins/apm/server/routes/mobile/get_mobile_main_statistics_by_field.ts create mode 100644 x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts create mode 100644 x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts diff --git a/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts b/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts index 808894dab55efb..d5efb1dbc2c97d 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts @@ -22,6 +22,7 @@ export type ApmApplicationMetricFields = Partial<{ 'faas.timeout': number; 'faas.coldstart_duration': number; 'faas.duration': number; + 'application.launch.time': number; }>; export type ApmUserAgentFields = Partial<{ @@ -88,6 +89,7 @@ export type ApmFields = Fields<{ 'error.grouping_key': string; 'error.grouping_name': string; 'error.id': string; + 'error.type': string; 'event.ingested': number; 'event.name': string; 'event.outcome': string; diff --git a/packages/kbn-apm-synthtrace-client/src/lib/apm/mobile_device.ts b/packages/kbn-apm-synthtrace-client/src/lib/apm/mobile_device.ts index eddb7d6c99d183..252590104e7a23 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/apm/mobile_device.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/apm/mobile_device.ts @@ -9,8 +9,10 @@ import { Entity } from '../entity'; import { Span } from './span'; import { Transaction } from './transaction'; -import { ApmFields, SpanParams, GeoLocation } from './apm_fields'; +import { ApmFields, SpanParams, GeoLocation, ApmApplicationMetricFields } from './apm_fields'; import { generateLongId } from '../utils/generate_id'; +import { Metricset } from './metricset'; +import { ApmError } from './apm_error'; export interface DeviceInfo { manufacturer: string; @@ -115,6 +117,7 @@ export class MobileDevice extends Entity { return this; } + // FIXME synthtrace shouldn't have side-effects like this. We should use an API like .session() which returns a session startNewSession() { this.fields['session.id'] = generateLongId(); return this; @@ -238,4 +241,21 @@ export class MobileDevice extends Entity { return this.span(spanParameters); } + + appMetrics(metrics: ApmApplicationMetricFields) { + return new Metricset({ + ...this.fields, + 'metricset.name': 'app', + ...metrics, + }); + } + + crash({ message, groupingName }: { message: string; groupingName?: string }) { + return new ApmError({ + ...this.fields, + 'error.type': 'crash', + 'error.exception': [{ message, ...{ type: 'crash' } }], + 'error.grouping_name': groupingName || message, + }); + } } diff --git a/packages/kbn-apm-synthtrace/src/scenarios/mobile.ts b/packages/kbn-apm-synthtrace/src/scenarios/mobile.ts index 6db2d17b624f9f..0ca4abf07bf911 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/mobile.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/mobile.ts @@ -20,6 +20,16 @@ const ENVIRONMENT = getSynthtraceEnvironment(__filename); type DeviceMetadata = DeviceInfo & OSInfo; +const modelIdentifiersWithCrashes = [ + 'SM-G930F', + 'HUAWEI P2-0000', + 'Pixel 3a', + 'LG K10', + 'iPhone11,8', + 'Watch6,8', + 'iPad12,2', +]; + const ANDROID_DEVICES: DeviceMetadata[] = [ { manufacturer: 'Samsung', @@ -354,34 +364,40 @@ const scenario: Scenario = async ({ scenarioOpts, logger }) => { device.startNewSession(); const framework = device.fields['device.manufacturer'] === 'Apple' ? 'iOS' : 'Android Activity'; + const couldCrash = modelIdentifiersWithCrashes.includes( + device.fields['device.model.identifier'] ?? '' + ); + const startTx = device + .transaction('Start View - View Appearing', framework) + .timestamp(timestamp) + .duration(500) + .success() + .children( + device + .span({ + spanName: 'onCreate', + spanType: 'app', + spanSubtype: 'external', + 'service.target.type': 'http', + 'span.destination.service.resource': 'external', + }) + .duration(50) + .success() + .timestamp(timestamp + 20), + device + .httpSpan({ + spanName: 'GET backend:1234', + httpMethod: 'GET', + httpUrl: 'https://backend:1234/api/start', + }) + .duration(800) + .failure() + .timestamp(timestamp + 400) + ); return [ - device - .transaction('Start View - View Appearing', framework) - .timestamp(timestamp) - .duration(500) - .success() - .children( - device - .span({ - spanName: 'onCreate', - spanType: 'app', - spanSubtype: 'external', - 'service.target.type': 'http', - 'span.destination.service.resource': 'external', - }) - .duration(50) - .success() - .timestamp(timestamp + 20), - device - .httpSpan({ - spanName: 'GET backend:1234', - httpMethod: 'GET', - httpUrl: 'https://backend:1234/api/start', - }) - .duration(800) - .failure() - .timestamp(timestamp + 400) - ), + couldCrash && index % 2 === 0 + ? startTx.errors(device.crash({ message: 'error' }).timestamp(timestamp)) + : startTx, device .transaction('Second View - View Appearing', framework) .timestamp(10000 + timestamp) @@ -418,7 +434,23 @@ const scenario: Scenario = async ({ scenarioOpts, logger }) => { }); }; - return [...androidDevices, ...iOSDevices].map((device) => sessionTransactions(device)); + const appLaunchMetrics = (device: MobileDevice) => { + return clickRate.generator((timestamp, index) => + device + .appMetrics({ + 'application.launch.time': 100 * (index + 1), + }) + .timestamp(timestamp) + ); + }; + + return [ + ...androidDevices.flatMap((device) => [ + sessionTransactions(device), + appLaunchMetrics(device), + ]), + ...iOSDevices.map((device) => sessionTransactions(device)), + ]; }, }; }; diff --git a/x-pack/plugins/apm/common/data_source.ts b/x-pack/plugins/apm/common/data_source.ts index b951677a8cb656..9282fb372ac721 100644 --- a/x-pack/plugins/apm/common/data_source.ts +++ b/x-pack/plugins/apm/common/data_source.ts @@ -13,7 +13,8 @@ type AnyApmDocumentType = | ApmDocumentType.TransactionMetric | ApmDocumentType.TransactionEvent | ApmDocumentType.ServiceDestinationMetric - | ApmDocumentType.ServiceSummaryMetric; + | ApmDocumentType.ServiceSummaryMetric + | ApmDocumentType.ErrorEvent; export interface ApmDataSource< TDocumentType extends AnyApmDocumentType = AnyApmDocumentType diff --git a/x-pack/plugins/apm/common/document_type.ts b/x-pack/plugins/apm/common/document_type.ts index 333b9f69e0d0fa..92a17c3125a961 100644 --- a/x-pack/plugins/apm/common/document_type.ts +++ b/x-pack/plugins/apm/common/document_type.ts @@ -11,6 +11,7 @@ export enum ApmDocumentType { TransactionEvent = 'transactionEvent', ServiceDestinationMetric = 'serviceDestinationMetric', ServiceSummaryMetric = 'serviceSummaryMetric', + ErrorEvent = 'error', } export type ApmServiceTransactionDocumentType = diff --git a/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap b/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap index 3ddc94bde02557..9dfb15ed9cb058 100644 --- a/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap +++ b/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap @@ -90,6 +90,8 @@ exports[`Error ERROR_LOG_MESSAGE 1`] = `undefined`; exports[`Error ERROR_PAGE_URL 1`] = `undefined`; +exports[`Error ERROR_TYPE 1`] = `undefined`; + exports[`Error EVENT_NAME 1`] = `undefined`; exports[`Error EVENT_OUTCOME 1`] = `undefined`; @@ -417,6 +419,8 @@ exports[`Span ERROR_LOG_MESSAGE 1`] = `undefined`; exports[`Span ERROR_PAGE_URL 1`] = `undefined`; +exports[`Span ERROR_TYPE 1`] = `undefined`; + exports[`Span EVENT_NAME 1`] = `undefined`; exports[`Span EVENT_OUTCOME 1`] = `"unknown"`; @@ -740,6 +744,8 @@ exports[`Transaction ERROR_LOG_MESSAGE 1`] = `undefined`; exports[`Transaction ERROR_PAGE_URL 1`] = `undefined`; +exports[`Transaction ERROR_TYPE 1`] = `undefined`; + exports[`Transaction EVENT_NAME 1`] = `undefined`; exports[`Transaction EVENT_OUTCOME 1`] = `"unknown"`; diff --git a/x-pack/plugins/apm/common/es_fields/apm.ts b/x-pack/plugins/apm/common/es_fields/apm.ts index 9a1dd15f94a756..141be323659565 100644 --- a/x-pack/plugins/apm/common/es_fields/apm.ts +++ b/x-pack/plugins/apm/common/es_fields/apm.ts @@ -109,6 +109,7 @@ export const ERROR_EXC_MESSAGE = 'error.exception.message'; // only to be used i export const ERROR_EXC_HANDLED = 'error.exception.handled'; // only to be used in es queries, since error.exception is now an array export const ERROR_EXC_TYPE = 'error.exception.type'; export const ERROR_PAGE_URL = 'error.page.url'; +export const ERROR_TYPE = 'error.type'; // METRICS export const METRIC_SYSTEM_FREE_MEMORY = 'system.memory.actual.free'; diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/generate_data.ts b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/generate_data.ts new file mode 100644 index 00000000000000..5b12bd58b76be3 --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/generate_data.ts @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { apm, timerange } from '@kbn/apm-synthtrace-client'; + +export const SERVICE_VERSIONS = ['2.3', '1.2', '1.1']; + +export function generateMobileData({ from, to }: { from: number; to: number }) { + const galaxy10 = apm + .mobileApp({ + name: 'synth-android', + environment: 'production', + agentName: 'android/java', + }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[0] }) + .deviceInfo({ + manufacturer: 'Samsung', + modelIdentifier: 'SM-G973F', + modelName: 'Galaxy S10', + }) + .osInfo({ + osType: 'android', + osVersion: '10', + osFull: 'Android 10, API level 29, BUILD A022MUBU2AUD1', + runtimeVersion: '2.1.0', + }) + .setGeoInfo({ + clientIp: '223.72.43.22', + cityName: 'Beijing', + continentName: 'Asia', + countryIsoCode: 'CN', + countryName: 'China', + regionIsoCode: 'CN-BJ', + regionName: 'Beijing', + location: { coordinates: [116.3861, 39.9143], type: 'Point' }, + }) + .setNetworkConnection({ type: 'wifi' }); + + const galaxy7 = apm + .mobileApp({ + name: 'synth-android', + environment: 'production', + agentName: 'android/java', + }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[1] }) + .deviceInfo({ + manufacturer: 'Samsung', + modelIdentifier: 'SM-G930F', + modelName: 'Galaxy S7', + }) + .osInfo({ + osType: 'android', + osVersion: '10', + osFull: 'Android 10, API level 29, BUILD A022MUBU2AUD1', + runtimeVersion: '2.1.0', + }) + .setGeoInfo({ + clientIp: '223.72.43.22', + cityName: 'Beijing', + continentName: 'Asia', + countryIsoCode: 'CN', + countryName: 'China', + regionIsoCode: 'CN-BJ', + regionName: 'Beijing', + location: { coordinates: [116.3861, 39.9143], type: 'Point' }, + }) + .setNetworkConnection({ + type: 'cell', + subType: 'edge', + carrierName: 'M1 Limited', + carrierMNC: '03', + carrierICC: 'SG', + carrierMCC: '525', + }); + + const huaweiP2 = apm + .mobileApp({ + name: 'synth-android', + environment: 'production', + agentName: 'android/java', + }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[2] }) + .deviceInfo({ + manufacturer: 'Huawei', + modelIdentifier: 'HUAWEI P2-0000', + modelName: 'HuaweiP2', + }) + .osInfo({ + osType: 'android', + osVersion: '10', + osFull: 'Android 10, API level 29, BUILD A022MUBU2AUD1', + runtimeVersion: '2.1.0', + }) + .setGeoInfo({ + clientIp: '20.24.184.101', + cityName: 'Singapore', + continentName: 'Asia', + countryIsoCode: 'SG', + countryName: 'Singapore', + location: { coordinates: [103.8554, 1.3036], type: 'Point' }, + }) + .setNetworkConnection({ + type: 'cell', + subType: 'edge', + carrierName: 'Osaka Gas Business Create Co., Ltd.', + carrierMNC: '17', + carrierICC: 'JP', + carrierMCC: '440', + }); + + return timerange(from, to) + .interval('5m') + .rate(1) + .generator((timestamp) => { + galaxy10.startNewSession(); + galaxy7.startNewSession(); + huaweiP2.startNewSession(); + return [ + galaxy10 + .transaction('Start View - View Appearing', 'Android Activity') + .timestamp(timestamp) + .duration(500) + .success() + .children( + galaxy10 + .span({ + spanName: 'onCreate', + spanType: 'app', + spanSubtype: 'external', + 'service.target.type': 'http', + 'span.destination.service.resource': 'external', + }) + .duration(50) + .success() + .timestamp(timestamp + 20), + galaxy10 + .httpSpan({ + spanName: 'GET backend:1234', + httpMethod: 'GET', + httpUrl: 'https://backend:1234/api/start', + }) + .duration(800) + .success() + .timestamp(timestamp + 400) + ), + galaxy10 + .transaction('Second View - View Appearing', 'Android Activity') + .timestamp(10000 + timestamp) + .duration(300) + .failure() + .children( + galaxy10 + .httpSpan({ + spanName: 'GET backend:1234', + httpMethod: 'GET', + httpUrl: 'https://backend:1234/api/second', + }) + .duration(400) + .success() + .timestamp(10000 + timestamp + 250) + ), + huaweiP2 + .transaction('Start View - View Appearing', 'huaweiP2 Activity') + .timestamp(timestamp) + .duration(20) + .success() + .children( + huaweiP2 + .span({ + spanName: 'onCreate', + spanType: 'app', + spanSubtype: 'external', + 'service.target.type': 'http', + 'span.destination.service.resource': 'external', + }) + .duration(50) + .success() + .timestamp(timestamp + 20), + huaweiP2 + .httpSpan({ + spanName: 'GET backend:1234', + httpMethod: 'GET', + httpUrl: 'https://backend:1234/api/start', + }) + .duration(800) + .success() + .timestamp(timestamp + 400) + ), + galaxy7 + .transaction('Start View - View Appearing', 'Android Activity') + .timestamp(timestamp) + .duration(20) + .success() + .children( + galaxy7 + .span({ + spanName: 'onCreate', + spanType: 'app', + spanSubtype: 'external', + 'service.target.type': 'http', + 'span.destination.service.resource': 'external', + }) + .duration(50) + .success() + .timestamp(timestamp + 20), + galaxy7 + .httpSpan({ + spanName: 'GET backend:1234', + httpMethod: 'GET', + httpUrl: 'https://backend:1234/api/start', + }) + .duration(800) + .success() + .timestamp(timestamp + 400) + ), + ]; + }); +} diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/mobile_transactions.cy.ts b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/mobile_transactions.cy.ts new file mode 100644 index 00000000000000..85cf055507f3bb --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/mobile_transactions.cy.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import url from 'url'; +import { synthtrace } from '../../../../synthtrace'; +import { generateMobileData } from './generate_data'; + +const start = '2021-10-10T00:00:00.000Z'; +const end = '2021-10-10T00:15:00.000Z'; + +const mobileTransactionsPageHref = url.format({ + pathname: '/app/apm/mobile-services/synth-android/transactions', + query: { + rangeFrom: start, + rangeTo: end, + }, +}); + +describe('Mobile transactions page', () => { + beforeEach(() => { + cy.loginAsViewerUser(); + }); + + describe('when data is loaded', () => { + before(() => { + synthtrace.index( + generateMobileData({ + from: new Date(start).getTime(), + to: new Date(end).getTime(), + }) + ); + }); + + after(() => { + synthtrace.clean(); + }); + + describe('when click on tab shows correct table', () => { + it('shows version tab', () => { + cy.visitKibana(mobileTransactionsPageHref); + cy.getByTestSubj('apmAppVersionTab') + .click() + .should('have.attr', 'aria-selected', 'true'); + cy.url().should('include', 'mobileSelectedTab=app_version_tab'); + }); + + it('shows OS version tab', () => { + cy.visitKibana(mobileTransactionsPageHref); + cy.getByTestSubj('apmOsVersionTab') + .click() + .should('have.attr', 'aria-selected', 'true'); + cy.url().should('include', 'mobileSelectedTab=os_version_tab'); + }); + + it('shows devices tab', () => { + cy.visitKibana(mobileTransactionsPageHref); + cy.getByTestSubj('apmDevicesTab') + .click() + .should('have.attr', 'aria-selected', 'true'); + cy.url().should('include', 'mobileSelectedTab=devices_tab'); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/index.tsx index be6963994c6399..ce06e04683af98 100644 --- a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/index.tsx @@ -16,11 +16,11 @@ import { useHistory } from 'react-router-dom'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; import { useApmParams } from '../../../../hooks/use_apm_params'; import { useTimeRange } from '../../../../hooks/use_time_range'; -import { TransactionsTable } from '../../../shared/transactions_table'; import { replace } from '../../../shared/links/url_helpers'; import { getKueryWithMobileFilters } from '../../../../../common/utils/get_kuery_with_mobile_filters'; import { MobileTransactionCharts } from './transaction_charts'; import { MobileTreemap } from '../charts/mobile_treemap'; +import { TransactionOverviewTabs } from './transaction_overview_tabs'; export function MobileTransactionOverview() { const { @@ -37,6 +37,7 @@ export function MobileTransactionOverview() { kuery, offset, comparisonEnabled, + mobileSelectedTab, }, } = useApmParams('/mobile-services/{serviceName}/transactions'); @@ -88,15 +89,14 @@ export function MobileTransactionOverview() { /> - diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/app_version_tab.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/app_version_tab.tsx new file mode 100644 index 00000000000000..deafdeb59d3c58 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/app_version_tab.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { TabContentProps } from '.'; +import { isPending } from '../../../../../hooks/use_fetcher'; +import { StatsList } from './stats_list'; +import { SERVICE_VERSION } from '../../../../../../common/es_fields/apm'; +import { useMobileStatisticsFetcher } from './use_mobile_statistics_fetcher'; + +function AppVersionTab({ + environment, + kuery, + start, + end, + comparisonEnabled, + offset, +}: TabContentProps) { + const { + mainStatistics, + mainStatisticsStatus, + detailedStatistics, + detailedStatisticsStatus, + } = useMobileStatisticsFetcher({ + field: SERVICE_VERSION, + environment, + kuery, + start, + end, + comparisonEnabled, + offset, + }); + + return ( + + ); +} + +export const appVersionTab = { + dataTestSubj: 'apmAppVersionTab', + key: 'app_version_tab', + label: i18n.translate( + 'xpack.apm.mobile.transactions.overview.tabs.app.version', + { + defaultMessage: 'App version', + } + ), + component: AppVersionTab, +}; diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/devices_tab.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/devices_tab.tsx new file mode 100644 index 00000000000000..4d2f18b0467093 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/devices_tab.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { TabContentProps } from '.'; +import { isPending } from '../../../../../hooks/use_fetcher'; +import { StatsList } from './stats_list'; +import { useMobileStatisticsFetcher } from './use_mobile_statistics_fetcher'; +import { DEVICE_MODEL_IDENTIFIER } from '../../../../../../common/es_fields/apm'; + +function DevicesTab({ + environment, + kuery, + start, + end, + comparisonEnabled, + offset, +}: TabContentProps) { + const { + mainStatistics, + mainStatisticsStatus, + detailedStatistics, + detailedStatisticsStatus, + } = useMobileStatisticsFetcher({ + field: DEVICE_MODEL_IDENTIFIER, + environment, + kuery, + start, + end, + comparisonEnabled, + offset, + }); + + return ( + + ); +} + +export const devicesTab = { + dataTestSubj: 'apmDevicesTab', + key: 'devices_tab', + label: i18n.translate('xpack.apm.mobile.transactions.overview.tabs.devices', { + defaultMessage: 'Devices', + }), + component: DevicesTab, +}; diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/index.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/index.tsx new file mode 100644 index 00000000000000..c986f5903b7b55 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/index.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { EuiSpacer, EuiTabs, EuiTab } from '@elastic/eui'; +import { push } from '../../../../shared/links/url_helpers'; +import { transactionsTab } from './transactions_tab'; +import { osVersionTab } from './os_version_tab'; +import { appVersionTab } from './app_version_tab'; +import { devicesTab } from './devices_tab'; + +export interface TabContentProps { + agentName?: string; + environment: string; + start: string; + end: string; + kuery: string; + comparisonEnabled: boolean; + offset?: string; + mobileSelectedTab?: string; +} + +const tabs = [transactionsTab, appVersionTab, osVersionTab, devicesTab]; + +export function TransactionOverviewTabs({ + agentName, + environment, + start, + end, + kuery, + comparisonEnabled, + offset, + mobileSelectedTab, +}: TabContentProps) { + const history = useHistory(); + + const { component: TabContent } = + tabs.find((tab) => tab.key === mobileSelectedTab) ?? transactionsTab; + return ( + <> + + {tabs.map(({ dataTestSubj, key, label }) => ( + { + push(history, { + query: { + mobileSelectedTab: key, + }, + }); + }} + > + {label} + + ))} + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/os_version_tab.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/os_version_tab.tsx new file mode 100644 index 00000000000000..6eee1f01aae9f1 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/os_version_tab.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { TabContentProps } from '.'; +import { isPending } from '../../../../../hooks/use_fetcher'; +import { StatsList } from './stats_list'; +import { useMobileStatisticsFetcher } from './use_mobile_statistics_fetcher'; +import { HOST_OS_VERSION } from '../../../../../../common/es_fields/apm'; + +function OSVersionTab({ + environment, + kuery, + start, + end, + comparisonEnabled, + offset, +}: TabContentProps) { + const { + mainStatistics, + mainStatisticsStatus, + detailedStatistics, + detailedStatisticsStatus, + } = useMobileStatisticsFetcher({ + field: HOST_OS_VERSION, + environment, + kuery, + start, + end, + comparisonEnabled, + offset, + }); + + return ( + + ); +} + +export const osVersionTab = { + dataTestSubj: 'apmOsVersionTab', + key: 'os_version_tab', + label: i18n.translate( + 'xpack.apm.mobile.transactions.overview.tabs.os.version', + { + defaultMessage: 'OS version', + } + ), + component: OSVersionTab, +}; diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/get_columns.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/get_columns.tsx new file mode 100644 index 00000000000000..18ac252011357b --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/get_columns.tsx @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { RIGHT_ALIGNMENT, EuiText } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { ValuesType } from 'utility-types'; +import { APIReturnType } from '../../../../../../services/rest/create_call_apm_api'; +import { + ChartType, + getTimeSeriesColor, +} from '../../../../../shared/charts/helper/get_timeseries_color'; +import { SparkPlot } from '../../../../../shared/charts/spark_plot'; +import { isTimeComparison } from '../../../../../shared/time_comparison/get_comparison_options'; +import { + asMillisecondDuration, + asPercent, + asTransactionRate, +} from '../../../../../../../common/utils/formatters'; +import { ITableColumn } from '../../../../../shared/managed_table'; + +type MobileMainStatisticsByField = + APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/main_statistics'>; + +type MobileMainStatisticsByFieldItem = ValuesType< + MobileMainStatisticsByField['mainStatistics'] +>; + +type MobileDetailedStatisticsByField = + APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics'>; + +export function getColumns({ + agentName, + detailedStatisticsLoading, + detailedStatistics, + comparisonEnabled, + offset, +}: { + agentName?: string; + detailedStatisticsLoading: boolean; + detailedStatistics: MobileDetailedStatisticsByField; + comparisonEnabled?: boolean; + offset?: string; +}): Array> { + return [ + // version/device + { + field: 'name', + name: i18n.translate( + 'xpack.apm.mobile.transactions.overview.table.nameColumnLabel', + { + defaultMessage: 'Name', + } + ), + }, + // latency + { + field: 'latency', + name: i18n.translate( + 'xpack.apm.mobile.transactions.overview.table.latencyColumnAvgLabel', + { + defaultMessage: 'Latency (avg.)', + } + ), + align: RIGHT_ALIGNMENT, + render: (_, { latency, name }) => { + const currentPeriodTimeseries = + detailedStatistics?.currentPeriod?.[name]?.latency; + const previousPeriodTimeseries = + detailedStatistics?.previousPeriod?.[name]?.latency; + + const { currentPeriodColor, previousPeriodColor } = getTimeSeriesColor( + ChartType.LATENCY_AVG + ); + + return ( + + ); + }, + }, + // throughput + { + field: 'throughput', + name: i18n.translate( + 'xpack.apm.mobile.transactions.overview.table.throughputColumnAvgLabel', + { defaultMessage: 'Throughput' } + ), + align: RIGHT_ALIGNMENT, + render: (_, { throughput, name }) => { + const currentPeriodTimeseries = + detailedStatistics?.currentPeriod?.[name]?.throughput; + const previousPeriodTimeseries = + detailedStatistics?.previousPeriod?.[name]?.throughput; + + const { currentPeriodColor, previousPeriodColor } = getTimeSeriesColor( + ChartType.THROUGHPUT + ); + + return ( + + ); + }, + }, + // crash rate + { + field: 'crashRate', + name: i18n.translate( + 'xpack.apm.mobile.transactions.overview.table.crashRateColumnLabel', + { + defaultMessage: 'Crash rate', + } + ), + align: RIGHT_ALIGNMENT, + render: (_, { crashRate }) => { + return ( + + {asPercent(crashRate, 1)} + + ); + }, + }, + ]; +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/index.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/index.tsx new file mode 100644 index 00000000000000..ab71f49421ddd3 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/index.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { ManagedTable } from '../../../../../shared/managed_table'; +import { APIReturnType } from '../../../../../../services/rest/create_call_apm_api'; +import { getColumns } from './get_columns'; + +type MobileMainStatisticsByField = + APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/main_statistics'>['mainStatistics']; + +type MobileDetailedStatisticsByField = + APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics'>; + +interface Props { + isLoading: boolean; + mainStatistics: MobileMainStatisticsByField; + detailedStatisticsLoading: boolean; + detailedStatistics: MobileDetailedStatisticsByField; + comparisonEnabled?: boolean; + offset?: string; +} +export function StatsList({ + isLoading, + mainStatistics, + detailedStatisticsLoading, + detailedStatistics, + comparisonEnabled, + offset, +}: Props) { + const columns = useMemo(() => { + return getColumns({ + detailedStatisticsLoading, + detailedStatistics, + comparisonEnabled, + offset, + }); + }, [ + detailedStatisticsLoading, + detailedStatistics, + comparisonEnabled, + offset, + ]); + return ( + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/transactions_tab.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/transactions_tab.tsx new file mode 100644 index 00000000000000..4fef8262e63056 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/transactions_tab.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { TabContentProps } from '.'; +import { TransactionsTable } from '../../../../shared/transactions_table'; + +function TransactionsTab({ environment, kuery, start, end }: TabContentProps) { + return ( + + ); +} + +export const transactionsTab = { + dataTestSubj: 'apmTransactionsTab', + key: 'transactions', + label: i18n.translate( + 'xpack.apm.mobile.transactions.overview.tabs.transactions', + { + defaultMessage: 'Transactions', + } + ), + component: TransactionsTab, +}; diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/use_mobile_statistics_fetcher.ts b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/use_mobile_statistics_fetcher.ts new file mode 100644 index 00000000000000..4c3bd48e5e089f --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/use_mobile_statistics_fetcher.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { useApmServiceContext } from '../../../../../context/apm_service/use_apm_service_context'; +import { useFetcher } from '../../../../../hooks/use_fetcher'; +import { isTimeComparison } from '../../../../shared/time_comparison/get_comparison_options'; + +const INITIAL_STATE_MAIN_STATISTICS = { + mainStatistics: [], + requestId: undefined, + totalItems: 0, +}; + +const INITIAL_STATE_DETAILED_STATISTICS = { + currentPeriod: {}, + previousPeriod: {}, +}; + +interface Props { + field: string; + environment: string; + start: string; + end: string; + kuery: string; + comparisonEnabled: boolean; + offset?: string; +} + +export function useMobileStatisticsFetcher({ + field, + environment, + start, + end, + kuery, + comparisonEnabled, + offset, +}: Props) { + const { serviceName } = useApmServiceContext(); + + const { data = INITIAL_STATE_MAIN_STATISTICS, status: mainStatisticsStatus } = + useFetcher( + (callApmApi) => { + if (start && end) { + return callApmApi( + 'GET /internal/apm/mobile-services/{serviceName}/main_statistics', + { + params: { + path: { serviceName }, + query: { + environment, + kuery, + start, + end, + field, + }, + }, + } + ).then((response) => { + return { + // Everytime the main statistics is refetched, updates the requestId making the comparison API to be refetched. + requestId: uuidv4(), + mainStatistics: response.mainStatistics, + totalItems: response.mainStatistics.length, + }; + }); + } + }, + [environment, start, end, kuery, serviceName, field] + ); + + const { mainStatistics, requestId, totalItems } = data; + + const { + data: detailedStatistics = INITIAL_STATE_DETAILED_STATISTICS, + status: detailedStatisticsStatus, + } = useFetcher( + (callApmApi) => { + if (totalItems && start && end) { + return callApmApi( + 'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics', + { + params: { + path: { serviceName }, + query: { + environment, + kuery, + start, + end, + field, + fieldValues: JSON.stringify( + data?.mainStatistics.map(({ name }) => name).sort() + ), + offset: + comparisonEnabled && isTimeComparison(offset) + ? offset + : undefined, + }, + }, + } + ); + } + }, + // only fetches agg results when requestId changes + // eslint-disable-next-line react-hooks/exhaustive-deps + [requestId], + { preservePreviousData: false } + ); + + return { + mainStatistics, + mainStatisticsStatus, + detailedStatistics, + detailedStatisticsStatus, + }; +} diff --git a/x-pack/plugins/apm/public/components/routing/mobile_service_detail/index.tsx b/x-pack/plugins/apm/public/components/routing/mobile_service_detail/index.tsx index 52e127c63f8055..7b8e4a120c2980 100644 --- a/x-pack/plugins/apm/public/components/routing/mobile_service_detail/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/mobile_service_detail/index.tsx @@ -146,6 +146,7 @@ export const mobileServiceDetail = { osVersion: t.string, appVersion: t.string, netConnectionType: t.string, + mobileSelectedTab: t.string, }), }), children: { diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx index 42d03902bc313e..c4df8a4f7e9a96 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx @@ -65,6 +65,7 @@ const DEFAULT_SORT = { }; interface Props { + hideTitle?: boolean; hideViewTransactionsLink?: boolean; isSingleColumn?: boolean; numberOfTransactionsPerPage?: number; @@ -81,6 +82,7 @@ interface Props { export function TransactionsTable({ fixedHeight = false, hideViewTransactionsLink = false, + hideTitle = false, isSingleColumn = true, numberOfTransactionsPerPage = 5, showPerPageOptions = true, @@ -294,32 +296,35 @@ export function TransactionsTable({ gutterSize="s" data-test-subj="transactionsGroupTable" > - - - - -

- {i18n.translate('xpack.apm.transactionsTable.title', { - defaultMessage: 'Transactions', - })} -

-
-
- {!hideViewTransactionsLink && ( + {!hideTitle && ( + + - - {i18n.translate('xpack.apm.transactionsTable.linkText', { - defaultMessage: 'View transactions', - })} - + +

+ {i18n.translate('xpack.apm.transactionsTable.title', { + defaultMessage: 'Transactions', + })} +

+
- )} -
-
+ {!hideViewTransactionsLink && ( + + + {i18n.translate('xpack.apm.transactionsTable.linkText', { + defaultMessage: 'View transactions', + })} + + + )} +
+
+ )} + {showMaxTransactionGroupsExceededWarning && maxTransactionGroupsExceeded && ( = diff --git a/x-pack/plugins/apm/server/routes/mobile/get_mobile_detailed_statistics_by_field.ts b/x-pack/plugins/apm/server/routes/mobile/get_mobile_detailed_statistics_by_field.ts new file mode 100644 index 00000000000000..d511b22b13274e --- /dev/null +++ b/x-pack/plugins/apm/server/routes/mobile/get_mobile_detailed_statistics_by_field.ts @@ -0,0 +1,214 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + kqlQuery, + rangeQuery, + termQuery, +} from '@kbn/observability-plugin/server'; +import { keyBy } from 'lodash'; +import { getBucketSize } from '../../../common/utils/get_bucket_size'; +import { getOffsetInMs } from '../../../common/utils/get_offset_in_ms'; +import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client'; +import { environmentQuery } from '../../../common/utils/environment_query'; +import { + SERVICE_NAME, + TRANSACTION_DURATION, +} from '../../../common/es_fields/apm'; +import { getLatencyValue } from '../../lib/helpers/latency_aggregation_type'; +import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; +import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_previous_period_coordinate'; +import { Coordinate } from '../../../typings/timeseries'; +import { ApmDocumentType } from '../../../common/document_type'; +import { RollupInterval } from '../../../common/rollup'; + +interface MobileDetailedStatistics { + fieldName: string; + latency: Coordinate[]; + throughput: Coordinate[]; +} + +export interface MobileDetailedStatisticsResponse { + currentPeriod: Record; + previousPeriod: Record; +} + +interface Props { + kuery: string; + apmEventClient: APMEventClient; + serviceName: string; + environment: string; + start: number; + end: number; + field: string; + fieldValues: string[]; + offset?: string; +} + +async function getMobileDetailedStatisticsByField({ + environment, + kuery, + serviceName, + field, + fieldValues, + apmEventClient, + start, + end, + offset, +}: Props) { + const { startWithOffset, endWithOffset } = getOffsetInMs({ + start, + end, + offset, + }); + + const { intervalString } = getBucketSize({ + start: startWithOffset, + end: endWithOffset, + minBucketSize: 60, + }); + + const response = await apmEventClient.search( + `get_mobile_detailed_statistics_by_field`, + { + apm: { + sources: [ + { + documentType: ApmDocumentType.TransactionEvent, + rollupInterval: RollupInterval.None, + }, + ], + }, + body: { + track_total_hits: false, + size: 0, + query: { + bool: { + filter: [ + ...termQuery(SERVICE_NAME, serviceName), + ...rangeQuery(startWithOffset, endWithOffset), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ], + }, + }, + aggs: { + detailed_statistics: { + terms: { + field, + include: fieldValues, + size: fieldValues.length, + }, + aggs: { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: startWithOffset, + max: endWithOffset, + }, + }, + aggs: { + latency: { + avg: { + field: TRANSACTION_DURATION, + }, + }, + }, + }, + }, + }, + }, + }, + } + ); + + const buckets = response.aggregations?.detailed_statistics.buckets ?? []; + + return buckets.map((bucket) => { + const fieldName = bucket.key as string; + const latency = bucket.timeseries.buckets.map((timeseriesBucket) => ({ + x: timeseriesBucket.key, + y: getLatencyValue({ + latencyAggregationType: LatencyAggregationType.avg, + aggregation: timeseriesBucket.latency, + }), + })); + const throughput = bucket.timeseries.buckets.map((timeseriesBucket) => ({ + x: timeseriesBucket.key, + y: timeseriesBucket.doc_count, + })); + + return { + fieldName, + latency, + throughput, + }; + }); +} + +export async function getMobileDetailedStatisticsByFieldPeriods({ + environment, + kuery, + serviceName, + field, + fieldValues, + apmEventClient, + start, + end, + offset, +}: Props): Promise { + const commonProps = { + environment, + kuery, + serviceName, + field, + fieldValues, + apmEventClient, + start, + end, + }; + + const currentPeriodPromise = getMobileDetailedStatisticsByField({ + ...commonProps, + }); + + const previousPeriodPromise = offset + ? getMobileDetailedStatisticsByField({ + ...commonProps, + offset, + }) + : []; + + const [currentPeriod, previousPeriod] = await Promise.all([ + currentPeriodPromise, + previousPeriodPromise, + ]); + + const firstCurrentPeriod = currentPeriod?.[0]; + return { + currentPeriod: keyBy(currentPeriod, 'fieldName'), + previousPeriod: keyBy( + previousPeriod.map((data) => { + return { + ...data, + latency: offsetPreviousPeriodCoordinates({ + currentPeriodTimeseries: firstCurrentPeriod?.latency, + previousPeriodTimeseries: data.latency, + }), + throughput: offsetPreviousPeriodCoordinates({ + currentPeriodTimeseries: firstCurrentPeriod?.throughput, + previousPeriodTimeseries: data.throughput, + }), + }; + }), + 'fieldName' + ), + }; +} diff --git a/x-pack/plugins/apm/server/routes/mobile/get_mobile_main_statistics_by_field.ts b/x-pack/plugins/apm/server/routes/mobile/get_mobile_main_statistics_by_field.ts new file mode 100644 index 00000000000000..a5783997e391bb --- /dev/null +++ b/x-pack/plugins/apm/server/routes/mobile/get_mobile_main_statistics_by_field.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + termQuery, + kqlQuery, + rangeQuery, +} from '@kbn/observability-plugin/server'; +import { merge } from 'lodash'; +import { + SERVICE_NAME, + SESSION_ID, + TRANSACTION_DURATION, + ERROR_TYPE, +} from '../../../common/es_fields/apm'; +import { environmentQuery } from '../../../common/utils/environment_query'; +import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client'; +import { getLatencyValue } from '../../lib/helpers/latency_aggregation_type'; +import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; +import { calculateThroughputWithRange } from '../../lib/helpers/calculate_throughput'; +import { ApmDocumentType } from '../../../common/document_type'; +import { RollupInterval } from '../../../common/rollup'; + +interface Props { + kuery: string; + apmEventClient: APMEventClient; + serviceName: string; + environment: string; + start: number; + end: number; + field: string; +} + +export interface MobileMainStatisticsResponse { + mainStatistics: Array<{ + name: string | number; + latency: number | null; + throughput: number; + crashRate?: number; + }>; +} + +export async function getMobileMainStatisticsByField({ + kuery, + apmEventClient, + serviceName, + environment, + start, + end, + field, +}: Props) { + async function getMobileTransactionEventStatistics() { + const response = await apmEventClient.search( + `get_mobile_main_statistics_by_field`, + { + apm: { + sources: [ + { + documentType: ApmDocumentType.TransactionEvent, + rollupInterval: RollupInterval.None, + }, + ], + }, + body: { + track_total_hits: false, + size: 0, + query: { + bool: { + filter: [ + ...termQuery(SERVICE_NAME, serviceName), + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ], + }, + }, + aggs: { + main_statistics: { + terms: { + field, + size: 1000, + }, + aggs: { + latency: { + avg: { + field: TRANSACTION_DURATION, + }, + }, + }, + }, + }, + }, + } + ); + + return ( + response.aggregations?.main_statistics.buckets.map((bucket) => { + return { + name: bucket.key, + latency: getLatencyValue({ + latencyAggregationType: LatencyAggregationType.avg, + aggregation: bucket.latency, + }), + throughput: calculateThroughputWithRange({ + start, + end, + value: bucket.doc_count, + }), + }; + }) ?? [] + ); + } + + async function getMobileErrorEventStatistics() { + const response = await apmEventClient.search( + `get_mobile_transaction_events_main_statistics_by_field`, + { + apm: { + sources: [ + { + documentType: ApmDocumentType.ErrorEvent, + rollupInterval: RollupInterval.None, + }, + ], + }, + body: { + track_total_hits: false, + size: 0, + query: { + bool: { + filter: [ + ...termQuery(SERVICE_NAME, serviceName), + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ], + }, + }, + aggs: { + main_statistics: { + terms: { + field, + size: 1000, + }, + aggs: { + sessions: { + cardinality: { + field: SESSION_ID, + }, + }, + crashes: { + filter: { + term: { + [ERROR_TYPE]: 'crash', + }, + }, + }, + }, + }, + }, + }, + } + ); + return ( + response.aggregations?.main_statistics.buckets.map((bucket) => { + return { + name: bucket.key, + crashRate: bucket.crashes.doc_count / bucket.sessions.value ?? 0, + }; + }) ?? [] + ); + } + + const [transactioEventStatistics, errorEventStatistics] = await Promise.all([ + getMobileTransactionEventStatistics(), + getMobileErrorEventStatistics(), + ]); + + const mainStatistics = merge(transactioEventStatistics, errorEventStatistics); + + return { mainStatistics }; +} diff --git a/x-pack/plugins/apm/server/routes/mobile/route.ts b/x-pack/plugins/apm/server/routes/mobile/route.ts index 3f6de9de1b696d..3323172a5e6d50 100644 --- a/x-pack/plugins/apm/server/routes/mobile/route.ts +++ b/x-pack/plugins/apm/server/routes/mobile/route.ts @@ -6,7 +6,7 @@ */ import * as t from 'io-ts'; -import { toNumberRt } from '@kbn/io-ts-utils'; +import { jsonRt, toNumberRt } from '@kbn/io-ts-utils'; import { getApmEventClient } from '../../lib/helpers/get_apm_event_client'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; import { environmentRt, kueryRt, rangeRt } from '../default_api_types'; @@ -26,6 +26,14 @@ import { getMobileTermsByField, MobileTermsByFieldResponse, } from './get_mobile_terms_by_field'; +import { + getMobileMainStatisticsByField, + MobileMainStatisticsResponse, +} from './get_mobile_main_statistics_by_field'; +import { + getMobileDetailedStatisticsByFieldPeriods, + MobileDetailedStatisticsResponse, +} from './get_mobile_detailed_statistics_by_field'; import { getMobileMostUsedCharts, MobileMostUsedChartResponse, @@ -329,6 +337,84 @@ const mobileTermsByFieldRoute = createApmServerRoute({ }, }); +const mobileMainStatisticsByField = createApmServerRoute({ + endpoint: 'GET /internal/apm/mobile-services/{serviceName}/main_statistics', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + kueryRt, + rangeRt, + environmentRt, + t.type({ + field: t.string, + }), + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async (resources): Promise => { + const apmEventClient = await getApmEventClient(resources); + const { params } = resources; + const { serviceName } = params.path; + const { kuery, environment, start, end, field } = params.query; + + return await getMobileMainStatisticsByField({ + kuery, + environment, + start, + end, + serviceName, + apmEventClient, + field, + }); + }, +}); + +const mobileDetailedStatisticsByField = createApmServerRoute({ + endpoint: + 'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + kueryRt, + rangeRt, + offsetRt, + environmentRt, + t.type({ + field: t.string, + fieldValues: jsonRt.pipe(t.array(t.string)), + }), + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async (resources): Promise => { + const apmEventClient = await getApmEventClient(resources); + const { params } = resources; + const { serviceName } = params.path; + const { kuery, environment, start, end, field, offset, fieldValues } = + params.query; + + return await getMobileDetailedStatisticsByFieldPeriods({ + kuery, + environment, + start, + end, + serviceName, + apmEventClient, + field, + fieldValues, + offset, + }); + }, +}); + export const mobileRouteRepository = { ...mobileFiltersRoute, ...mobileChartsRoute, @@ -337,4 +423,6 @@ export const mobileRouteRepository = { ...mobileStatsRoute, ...mobileLocationStatsRoute, ...mobileTermsByFieldRoute, + ...mobileMainStatisticsByField, + ...mobileDetailedStatisticsByField, }; diff --git a/x-pack/test/apm_api_integration/tests/mobile/generate_mobile_data.ts b/x-pack/test/apm_api_integration/tests/mobile/generate_mobile_data.ts index cd3b4ed6362720..91a8aac9bc3d36 100644 --- a/x-pack/test/apm_api_integration/tests/mobile/generate_mobile_data.ts +++ b/x-pack/test/apm_api_integration/tests/mobile/generate_mobile_data.ts @@ -7,6 +7,8 @@ import { apm, timerange } from '@kbn/apm-synthtrace-client'; import { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace'; +export const SERVICE_VERSIONS = ['2.3', '1.2', '1.1']; + export async function generateMobileData({ start, end, @@ -22,7 +24,7 @@ export async function generateMobileData({ environment: 'production', agentName: 'android/java', }) - .mobileDevice({ serviceVersion: '2.3' }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[0] }) .deviceInfo({ manufacturer: 'Samsung', modelIdentifier: 'SM-G973F', @@ -52,7 +54,7 @@ export async function generateMobileData({ environment: 'production', agentName: 'android/java', }) - .mobileDevice({ serviceVersion: '1.2' }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[1] }) .deviceInfo({ manufacturer: 'Samsung', modelIdentifier: 'SM-G930F', @@ -89,7 +91,7 @@ export async function generateMobileData({ environment: 'production', agentName: 'android/java', }) - .mobileDevice({ serviceVersion: '1.1' }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[2] }) .deviceInfo({ manufacturer: 'Huawei', modelIdentifier: 'HUAWEI P2-0000', @@ -222,6 +224,7 @@ export async function generateMobileData({ return [ galaxy10 .transaction('Start View - View Appearing', 'Android Activity') + .errors(galaxy10.crash({ message: 'error' }).timestamp(timestamp)) .timestamp(timestamp) .duration(500) .success() @@ -265,6 +268,7 @@ export async function generateMobileData({ ), huaweiP2 .transaction('Start View - View Appearing', 'huaweiP2 Activity') + .errors(huaweiP2.crash({ message: 'error' }).timestamp(timestamp)) .timestamp(timestamp) .duration(20) .success() @@ -292,6 +296,7 @@ export async function generateMobileData({ ), galaxy7 .transaction('Start View - View Appearing', 'Android Activity') + .errors(galaxy7.crash({ message: 'error' }).timestamp(timestamp)) .timestamp(timestamp) .duration(20) .success() diff --git a/x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts new file mode 100644 index 00000000000000..601e3b81e6dada --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { ENVIRONMENT_ALL } from '@kbn/apm-plugin/common/environment_filter_values'; +import { isEmpty } from 'lodash'; +import moment from 'moment'; +import { APIReturnType } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { generateMobileData, SERVICE_VERSIONS } from './generate_mobile_data'; + +type MobileDetailedStatisticsResponse = + APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics'>; + +export default function ApiTest({ getService }: FtrProviderContext) { + const apmApiClient = getService('apmApiClient'); + const registry = getService('registry'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const start = new Date('2023-01-01T00:00:00.000Z').getTime(); + const end = new Date('2023-01-01T00:15:00.000Z').getTime() - 1; + + async function getMobileDetailedStatisticsByField({ + environment = ENVIRONMENT_ALL.value, + kuery = '', + serviceName, + field, + offset, + }: { + environment?: string; + kuery?: string; + serviceName: string; + field: string; + offset?: string; + }) { + return await apmApiClient + .readUser({ + endpoint: 'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics', + params: { + path: { serviceName }, + query: { + environment, + start: moment(end).subtract(7, 'minutes').toISOString(), + end: new Date(end).toISOString(), + offset, + kuery, + field, + fieldValues: JSON.stringify(SERVICE_VERSIONS), + }, + }, + }) + .then(({ body }) => body); + } + + registry.when( + 'Mobile detailed statistics when data is not loaded', + { config: 'basic', archives: [] }, + () => { + describe('when no data', () => { + it('handles empty state', async () => { + const response = await getMobileDetailedStatisticsByField({ + serviceName: 'foo', + field: 'service.version', + }); + expect(response).to.be.eql({ currentPeriod: {}, previousPeriod: {} }); + }); + }); + } + ); + + registry.when( + 'Mobile detailed statistics when data is loaded', + { config: 'basic', archives: [] }, + () => { + before(async () => { + await generateMobileData({ + synthtraceEsClient, + start, + end, + }); + }); + + after(() => synthtraceEsClient.clean()); + + describe('when comparison is disable', () => { + it('returns current period data only', async () => { + const response = await getMobileDetailedStatisticsByField({ + serviceName: 'synth-android', + environment: 'production', + field: 'service.version', + }); + expect(isEmpty(response.currentPeriod)).to.be.equal(false); + expect(isEmpty(response.previousPeriod)).to.be.equal(true); + }); + }); + + describe('when comparison is enable', () => { + let mobiledetailedStatisticResponse: MobileDetailedStatisticsResponse; + + before(async () => { + mobiledetailedStatisticResponse = await getMobileDetailedStatisticsByField({ + serviceName: 'synth-android', + environment: 'production', + field: 'service.version', + offset: '8m', + }); + }); + it('returns some data for both periods', async () => { + expect(isEmpty(mobiledetailedStatisticResponse.currentPeriod)).to.be.equal(false); + expect(isEmpty(mobiledetailedStatisticResponse.previousPeriod)).to.be.equal(false); + }); + + it('returns same number of buckets for both periods', () => { + const currentPeriod = mobiledetailedStatisticResponse.currentPeriod[SERVICE_VERSIONS[0]]; + const previousPeriod = + mobiledetailedStatisticResponse.previousPeriod[SERVICE_VERSIONS[0]]; + + [ + [currentPeriod.latency, previousPeriod.latency], + [currentPeriod.throughput, previousPeriod.throughput], + ].forEach(([currentTimeseries, previousTimeseries]) => { + expect(currentTimeseries.length).to.equal(previousTimeseries.length); + }); + }); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts new file mode 100644 index 00000000000000..a58f6e58b99e69 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { ENVIRONMENT_ALL } from '@kbn/apm-plugin/common/environment_filter_values'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { generateMobileData } from './generate_mobile_data'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const apmApiClient = getService('apmApiClient'); + const registry = getService('registry'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const start = new Date('2023-01-01T00:00:00.000Z').getTime(); + const end = new Date('2023-01-01T00:15:00.000Z').getTime() - 1; + + async function getMobileMainStatisticsByField({ + environment = ENVIRONMENT_ALL.value, + kuery = '', + serviceName, + field, + }: { + environment?: string; + kuery?: string; + serviceName: string; + field: string; + }) { + return await apmApiClient + .readUser({ + endpoint: 'GET /internal/apm/mobile-services/{serviceName}/main_statistics', + params: { + path: { serviceName }, + query: { + environment, + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + kuery, + field, + }, + }, + }) + .then(({ body }) => body); + } + + registry.when( + 'Mobile main statistics when data is not loaded', + { config: 'basic', archives: [] }, + () => { + describe('when no data', () => { + it('handles empty state', async () => { + const response = await getMobileMainStatisticsByField({ + serviceName: 'foo', + field: 'service.version', + }); + expect(response.mainStatistics.length).to.be(0); + }); + }); + } + ); + + registry.when('Mobile main statistics', { config: 'basic', archives: [] }, () => { + before(async () => { + await generateMobileData({ + synthtraceEsClient, + start, + end, + }); + }); + + after(() => synthtraceEsClient.clean()); + + describe('when data is loaded', () => { + it('returns the correct data for App version', async () => { + const response = await getMobileMainStatisticsByField({ + serviceName: 'synth-android', + environment: 'production', + field: 'service.version', + }); + const fieldValues = response.mainStatistics.map((item) => item.name); + + expect(fieldValues).to.be.eql(['1.1', '1.2', '2.3']); + + const latencyValues = response.mainStatistics.map((item) => item.latency); + + expect(latencyValues).to.be.eql([172000, 20000, 20000]); + + const throughputValues = response.mainStatistics.map((item) => item.throughput); + expect(throughputValues).to.be.eql([ + 1.0000011111123457, 0.20000022222246913, 0.20000022222246913, + ]); + }); + it('returns the correct data for Os version', async () => { + const response = await getMobileMainStatisticsByField({ + serviceName: 'synth-android', + environment: 'production', + field: 'host.os.version', + }); + + const fieldValues = response.mainStatistics.map((item) => item.name); + + expect(fieldValues).to.be.eql(['10']); + + const latencyValues = response.mainStatistics.map((item) => item.latency); + + expect(latencyValues).to.be.eql([128571.42857142857]); + + const throughputValues = response.mainStatistics.map((item) => item.throughput); + expect(throughputValues).to.be.eql([1.4000015555572838]); + }); + it('returns the correct data for Devices', async () => { + const response = await getMobileMainStatisticsByField({ + serviceName: 'synth-android', + environment: 'production', + field: 'device.model.identifier', + }); + const fieldValues = response.mainStatistics.map((item) => item.name); + + expect(fieldValues).to.be.eql([ + 'HUAWEI P2-0000', + 'SM-G930F', + 'SM-G973F', + 'Pixel 7 Pro', + 'Pixel 8', + 'SM-G930F', + ]); + + const latencyValues = response.mainStatistics.map((item) => item.latency); + + expect(latencyValues).to.be.eql([400000, 20000, 20000, 20000, 20000, 20000]); + + const throughputValues = response.mainStatistics.map((item) => item.throughput); + expect(throughputValues).to.be.eql([ + 0.40000044444493826, 0.20000022222246913, 0.20000022222246913, 0.20000022222246913, + 0.20000022222246913, 0.20000022222246913, + ]); + }); + }); + }); +} From 61b56ce15f6744b4add7bb1c345a00f171154c7c Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 25 Apr 2023 16:50:40 +0200 Subject: [PATCH 10/13] [Discover] Add a deprecation badge to legacy stats setting (#155688) ## Summary We are adding a deprecation badge in 8.8 and removing this setting in 8.9 https://github.com/elastic/kibana/pull/155503 Screenshot 2023-04-25 at 10 28 21 --- packages/kbn-doc-links/src/get_doc_links.ts | 1 + src/plugins/discover/server/ui_settings.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index dc75ebee9a5ebd..29ac5472e37ddd 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -419,6 +419,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { dashboardSettings: `${KIBANA_DOCS}advanced-options.html#kibana-dashboard-settings`, indexManagement: `${ELASTICSEARCH_DOCS}index-mgmt.html`, kibanaSearchSettings: `${KIBANA_DOCS}advanced-options.html#kibana-search-settings`, + discoverSettings: `${KIBANA_DOCS}advanced-options.html#kibana-discover-settings`, visualizationSettings: `${KIBANA_DOCS}advanced-options.html#kibana-visualization-settings`, timelionSettings: `${KIBANA_DOCS}advanced-options.html#kibana-timelion-settings`, savedObjectsApiList: `${KIBANA_DOCS}saved-objects-api.html#saved-objects-api`, diff --git a/src/plugins/discover/server/ui_settings.ts b/src/plugins/discover/server/ui_settings.ts index 3c0fe05348324c..d6985033cdb1dc 100644 --- a/src/plugins/discover/server/ui_settings.ts +++ b/src/plugins/discover/server/ui_settings.ts @@ -137,6 +137,12 @@ export const getUiSettings: (docLinks: DocLinksServiceSetup) => Record Date: Tue, 25 Apr 2023 10:51:59 -0400 Subject: [PATCH 11/13] [Security Solution][Investigations] - Add kibana.alert.url (#155069) ## Summary This PR introduces the field `kibana.alert.url` to the alerts generated by al alert rule types. Functionality was added in [this PR](https://github.com/elastic/kibana/pull/148800) for 8.8 to allow users to link directly to the alert flyout. To be able to provide users with this field via our connectors, we are adding the url under the field `kibana.alert.url`. To test, create an alert of any type and you should see this field set in the alert flyout: image The url provided is a redirect path that contains the necessary information (space, id, index, and timestamp) to be able to redirect the user to a filtered alert page for the given alert and the detail flyout opened. This allows us to retain flexibility in the future for any changes that may occur with the alert flyout or an alert page. More on that can be found in the earlier pr: https://github.com/elastic/kibana/pull/148800 ### Testing 1. The `kibana.alert.url` field makes use of the `publicBaseUrl` configuration which must be set in your kibana.dev.yml for this field to be generated. Add the following to your yaml file. Note that if you use a `basePath`, it will have to be appended to the end of your `publicBaseUrl` path. ``` server.publicBaseUrl: 'http://localhost:5601' ``` with basePath: ``` server.basePath: '/someBasePath' server.publicBaseUrl: 'http://localhost:5601/someBasePath' ``` 2. Generate data and enable any rule type to get alerts. 3. Go to the alert page, click expand detail, and search for `kibana.alert.url` in the table. 4. Visit that url and you should see a filtered alert page with the details flyout opened ***Caveat - when grouping is enabled, the details flyout will not open as the table that it is attached to is not actually loaded at that point in time. When the table is loaded by either disabling grouping or opening the group, the details flyout will open --- .../src/default_alerts_as_data.ts | 2 +- .../security_solution/common/constants.ts | 1 + .../schemas/alerts/8.8.0/index.ts | 56 ++++++++++++++++++ .../detection_engine/schemas/alerts/index.ts | 37 ++++++------ .../common/utils/alert_detail_path.test.ts | 56 ++++++++++++++++++ .../common/utils/alert_detail_path.ts | 39 +++++++++++++ .../e2e/detection_alerts/alerts_details.cy.ts | 2 +- x-pack/plugins/security_solution/kibana.jsonc | 2 +- .../store/data_table/epic_local_storage.ts | 2 + .../public/detections/pages/alerts/index.tsx | 8 ++- .../__snapshots__/index.test.tsx.snap | 37 ++++++++---- .../event_details/expandable_event.tsx | 42 ++++++++------ .../use_get_alert_details_flyout_link.ts | 14 +++-- .../rule_types/__mocks__/es_results.ts | 2 + .../rule_types/__mocks__/threshold.ts | 2 + .../create_security_rule_type_wrapper.ts | 14 ++++- .../build_alert_group_from_sequence.test.ts | 16 +++++- .../eql/build_alert_group_from_sequence.ts | 57 ++++++++++++++----- .../rule_types/eql/wrap_sequences_factory.ts | 5 +- .../factories/utils/build_alert.test.ts | 19 +++++-- .../rule_types/factories/utils/build_alert.ts | 21 ++++++- .../factories/utils/build_bulk_body.ts | 6 +- .../rule_types/factories/wrap_hits_factory.ts | 34 ++++++----- .../new_terms/create_new_terms_alert_type.ts | 2 + .../new_terms/wrap_new_terms_alerts.test.ts | 19 ++++++- .../new_terms/wrap_new_terms_alerts.ts | 9 ++- .../group_and_bulk_create.ts | 1 + .../wrap_suppressed_alerts.ts | 9 ++- .../query/create_query_alert_type.test.ts | 2 + .../lib/detection_engine/rule_types/types.ts | 2 + .../utils/enrichments/__mocks__/alerts.ts | 2 + .../utils/search_after_bulk_create.test.ts | 1 + .../security_solution/server/plugin.ts | 1 + 33 files changed, 418 insertions(+), 104 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.8.0/index.ts create mode 100644 x-pack/plugins/security_solution/common/utils/alert_detail_path.test.ts create mode 100644 x-pack/plugins/security_solution/common/utils/alert_detail_path.ts diff --git a/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts b/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts index d803d6ca503b8d..c5ba5f59fb3847 100644 --- a/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts +++ b/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts @@ -94,7 +94,7 @@ const ALERT_RULE_TAGS = `${ALERT_RULE_NAMESPACE}.tags` as const; // kibana.alert.rule_type_id - rule type id for rule that generated this alert const ALERT_RULE_TYPE_ID = `${ALERT_RULE_NAMESPACE}.rule_type_id` as const; -// kibana.alert.url - allow our user to go back to the details url in kibana +// kibana.alert.url - url which will redirect users to a page related to the given alert const ALERT_URL = `${ALERT_NAMESPACE}.url` as const; // kibana.alert.rule.uuid - rule ID for rule that generated this alert diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 2551bc34f7fa44..7da50bd569a471 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -156,6 +156,7 @@ export const DATA_QUALITY_PATH = '/data_quality' as const; export const DETECTION_RESPONSE_PATH = '/detection_response' as const; export const DETECTIONS_PATH = '/detections' as const; export const ALERTS_PATH = '/alerts' as const; +export const ALERT_DETAILS_REDIRECT_PATH = `${ALERTS_PATH}/redirect` as const; export const RULES_PATH = '/rules' as const; export const RULES_CREATE_PATH = `${RULES_PATH}/create` as const; export const EXCEPTIONS_PATH = '/exceptions' as const; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.8.0/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.8.0/index.ts new file mode 100644 index 00000000000000..66c06b8406d3cb --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.8.0/index.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ALERT_URL, ALERT_UUID } from '@kbn/rule-data-utils'; +import type { AlertWithCommonFields800 } from '@kbn/rule-registry-plugin/common/schemas/8.0.0'; +import type { + Ancestor840, + BaseFields840, + EqlBuildingBlockFields840, + EqlShellFields840, + NewTermsFields840, +} from '../8.4.0'; + +/* DO NOT MODIFY THIS SCHEMA TO ADD NEW FIELDS. These types represent the alerts that shipped in 8.8.0. +Any changes to these types should be bug fixes so the types more accurately represent the alerts from 8.8.0. +If you are adding new fields for a new release of Kibana, create a new sibling folder to this one +for the version to be released and add the field(s) to the schema in that folder. +Then, update `../index.ts` to import from the new folder that has the latest schemas, add the +new schemas to the union of all alert schemas, and re-export the new schemas as the `*Latest` schemas. +*/ + +export type { Ancestor840 as Ancestor880 }; +export interface BaseFields880 extends BaseFields840 { + [ALERT_URL]: string | undefined; + [ALERT_UUID]: string; +} + +export interface WrappedFields880 { + _id: string; + _index: string; + _source: T; +} + +export type GenericAlert880 = AlertWithCommonFields800; + +export type EqlShellFields880 = EqlShellFields840 & BaseFields880; + +export type EqlBuildingBlockFields880 = EqlBuildingBlockFields840 & BaseFields880; + +export type NewTermsFields880 = NewTermsFields840 & BaseFields880; + +export type NewTermsAlert880 = NewTermsFields840 & BaseFields880; + +export type EqlBuildingBlockAlert880 = AlertWithCommonFields800; + +export type EqlShellAlert880 = AlertWithCommonFields800; + +export type DetectionAlert880 = + | GenericAlert880 + | EqlShellAlert880 + | EqlBuildingBlockAlert880 + | NewTermsAlert880; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts index 2fdf426f0aea0f..1d3e3f0d35f4f7 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts @@ -7,18 +7,18 @@ import type { DetectionAlert800 } from './8.0.0'; -import type { - Ancestor840, - BaseFields840, - DetectionAlert840, - WrappedFields840, - EqlBuildingBlockFields840, - EqlShellFields840, - NewTermsFields840, -} from './8.4.0'; - +import type { DetectionAlert840 } from './8.4.0'; import type { DetectionAlert860 } from './8.6.0'; import type { DetectionAlert870 } from './8.7.0'; +import type { + Ancestor880, + BaseFields880, + DetectionAlert880, + EqlBuildingBlockFields880, + EqlShellFields880, + NewTermsFields880, + WrappedFields880, +} from './8.8.0'; // When new Alert schemas are created for new Kibana versions, add the DetectionAlert type from the new version // here, e.g. `export type DetectionAlert = DetectionAlert800 | DetectionAlert820` if a new schema is created in 8.2.0 @@ -26,14 +26,15 @@ export type DetectionAlert = | DetectionAlert800 | DetectionAlert840 | DetectionAlert860 - | DetectionAlert870; + | DetectionAlert870 + | DetectionAlert880; export type { - Ancestor840 as AncestorLatest, - BaseFields840 as BaseFieldsLatest, - DetectionAlert860 as DetectionAlertLatest, - WrappedFields840 as WrappedFieldsLatest, - EqlBuildingBlockFields840 as EqlBuildingBlockFieldsLatest, - EqlShellFields840 as EqlShellFieldsLatest, - NewTermsFields840 as NewTermsFieldsLatest, + Ancestor880 as AncestorLatest, + BaseFields880 as BaseFieldsLatest, + DetectionAlert880 as DetectionAlertLatest, + WrappedFields880 as WrappedFieldsLatest, + EqlBuildingBlockFields880 as EqlBuildingBlockFieldsLatest, + EqlShellFields880 as EqlShellFieldsLatest, + NewTermsFields880 as NewTermsFieldsLatest, }; diff --git a/x-pack/plugins/security_solution/common/utils/alert_detail_path.test.ts b/x-pack/plugins/security_solution/common/utils/alert_detail_path.test.ts new file mode 100644 index 00000000000000..be827e082db143 --- /dev/null +++ b/x-pack/plugins/security_solution/common/utils/alert_detail_path.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { buildAlertDetailPath, getAlertDetailsUrl } from './alert_detail_path'; + +describe('alert_detail_path', () => { + const defaultArguments = { + alertId: 'testId', + index: 'testIndex', + timestamp: '2023-04-18T00:00:00.000Z', + }; + describe('buildAlertDetailPath', () => { + it('builds the alert detail path as expected', () => { + expect(buildAlertDetailPath(defaultArguments)).toMatchInlineSnapshot( + `"/alerts/redirect/testId?index=testIndex×tamp=2023-04-18T00:00:00.000Z"` + ); + }); + }); + describe('getAlertDetailsUrl', () => { + it('builds the alert detail path without a space id', () => { + expect( + getAlertDetailsUrl({ + ...defaultArguments, + basePath: 'http://somebasepath.com', + }) + ).toMatchInlineSnapshot( + `"http://somebasepath.com/app/security/alerts/redirect/testId?index=testIndex×tamp=2023-04-18T00:00:00.000Z"` + ); + }); + + it('builds the alert detail path with a space id', () => { + expect( + getAlertDetailsUrl({ + ...defaultArguments, + basePath: 'http://somebasepath.com', + spaceId: 'test-space', + }) + ).toMatchInlineSnapshot( + `"http://somebasepath.com/s/test-space/app/security/alerts/redirect/testId?index=testIndex×tamp=2023-04-18T00:00:00.000Z"` + ); + }); + + it('does not build the alert detail path without a basePath', () => { + expect( + getAlertDetailsUrl({ + ...defaultArguments, + spaceId: 'test-space', + }) + ).toBe(undefined); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/common/utils/alert_detail_path.ts b/x-pack/plugins/security_solution/common/utils/alert_detail_path.ts new file mode 100644 index 00000000000000..2fcc1b6687b7d1 --- /dev/null +++ b/x-pack/plugins/security_solution/common/utils/alert_detail_path.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; +import { ALERT_DETAILS_REDIRECT_PATH, APP_PATH } from '../constants'; + +export const buildAlertDetailPath = ({ + alertId, + index, + timestamp, +}: { + alertId: string; + index: string; + timestamp: string; +}) => `${ALERT_DETAILS_REDIRECT_PATH}/${alertId}?index=${index}×tamp=${timestamp}`; + +export const getAlertDetailsUrl = ({ + alertId, + index, + timestamp, + basePath, + spaceId, +}: { + alertId: string; + index: string; + timestamp: string; + basePath?: string; + spaceId?: string | null; +}) => { + const alertDetailPath = buildAlertDetailPath({ alertId, index, timestamp }); + const alertDetailPathWithAppPath = `${APP_PATH}${alertDetailPath}`; + return basePath + ? addSpaceIdToPath(basePath, spaceId ?? undefined, alertDetailPathWithAppPath) + : undefined; +}; diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_details.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_details.cy.ts index 947f92d3ec4aa2..a42f81481d5769 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_details.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_details.cy.ts @@ -134,7 +134,7 @@ describe('Alert details flyout', () => { cy.get('[data-test-subj="formatted-field-_id"]') .invoke('text') .then((alertId) => { - cy.visit(`http://localhost:5620/app/security/alerts/${alertId}`); + cy.visit(`http://localhost:5620/app/security/alerts/redirect/${alertId}`); cy.get('[data-test-subj="unifiedQueryInput"]').should('have.text', `_id: ${alertId}`); cy.get(ALERTS_COUNT).should('have.text', '1 alert'); cy.get(OVERVIEW_RULE).should('be.visible'); diff --git a/x-pack/plugins/security_solution/kibana.jsonc b/x-pack/plugins/security_solution/kibana.jsonc index 9401b69d11657d..a929c15b486414 100644 --- a/x-pack/plugins/security_solution/kibana.jsonc +++ b/x-pack/plugins/security_solution/kibana.jsonc @@ -32,6 +32,7 @@ "maps", "ruleRegistry", "sessionView", + "spaces", "taskManager", "threatIntelligence", "timelines", @@ -50,7 +51,6 @@ "ml", "newsfeed", "security", - "spaces", "usageCollection", "lists", "home", diff --git a/x-pack/plugins/security_solution/public/common/store/data_table/epic_local_storage.ts b/x-pack/plugins/security_solution/public/common/store/data_table/epic_local_storage.ts index 2afd961c7a9b70..4b0e5cdd2a0e38 100644 --- a/x-pack/plugins/security_solution/public/common/store/data_table/epic_local_storage.ts +++ b/x-pack/plugins/security_solution/public/common/store/data_table/epic_local_storage.ts @@ -21,6 +21,7 @@ const { applyDeltaToColumnWidth, changeViewMode, removeColumn, + toggleDetailPanel, updateColumnOrder, updateColumns, updateColumnWidth, @@ -46,6 +47,7 @@ const tableActionTypes = [ updateShowBuildingBlockAlertsFilter.type, updateTotalCount.type, updateIsLoading.type, + toggleDetailPanel.type, ]; export const createDataTableLocalStorageEpic = diff --git a/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx index 2d3a55c6b0cd4e..6a7bdc526e7501 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx @@ -10,7 +10,11 @@ import { Switch } from 'react-router-dom'; import { Route } from '@kbn/shared-ux-router'; import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; -import { ALERTS_PATH, SecurityPageName } from '../../../../common/constants'; +import { + ALERTS_PATH, + ALERT_DETAILS_REDIRECT_PATH, + SecurityPageName, +} from '../../../../common/constants'; import { NotFoundPage } from '../../../app/404'; import * as i18n from './translations'; import { DetectionEnginePage } from '../detection_engine/detection_engine'; @@ -31,7 +35,7 @@ const AlertsContainerComponent: React.FC = () => { {/* Redirect to the alerts page filtered for the given alert id */} - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap index 9717daac79baf5..c5bc7821278b58 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -49,18 +49,26 @@ Array [
- +
+ +
+
,
+
+
+
( )} - {handleOnEventClosed && ( - - - - )} - {isAlert && ( - - {(copy) => ( - - {i18n.SHARE_ALERT} - + + + {handleOnEventClosed && ( + + + + )} + {isAlert && alertDetailsLink && ( + + {(copy) => ( + + {i18n.SHARE_ALERT} + + )} + )} - - )} + + ); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_get_alert_details_flyout_link.ts b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_get_alert_details_flyout_link.ts index 1d2d1b5ea62135..9a074e16dc0b32 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_get_alert_details_flyout_link.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_get_alert_details_flyout_link.ts @@ -6,9 +6,10 @@ */ import { useMemo } from 'react'; +import { DEFAULT_PREVIEW_INDEX } from '../../../../../common/constants'; +import { buildAlertDetailPath } from '../../../../../common/utils/alert_detail_path'; import { useAppUrl } from '../../../../common/lib/kibana/hooks'; -import { ALERTS_PATH } from '../../../../../common/constants'; export const useGetAlertDetailsFlyoutLink = ({ _id, @@ -20,13 +21,16 @@ export const useGetAlertDetailsFlyoutLink = ({ timestamp: string; }) => { const { getAppUrl } = useAppUrl(); + const alertDetailPath = buildAlertDetailPath({ alertId: _id, index: _index, timestamp }); + const isPreviewAlert = _index.includes(DEFAULT_PREVIEW_INDEX); + // getAppUrl accounts for the users selected space const alertDetailsLink = useMemo(() => { - const url = getAppUrl({ - path: `${ALERTS_PATH}/${_id}?index=${_index}×tamp=${timestamp}`, - }); + if (isPreviewAlert) return null; + const url = getAppUrl({ path: alertDetailPath }); + // We use window.location.origin instead of http.basePath as the http.basePath has to be configured in config dev yml return `${window.location.origin}${url}`; - }, [_id, _index, getAppUrl, timestamp]); + }, [isPreviewAlert, getAppUrl, alertDetailPath]); return alertDetailsLink; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts index 9593dd5624c229..f852bfff48873b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts @@ -50,6 +50,7 @@ import { ALERT_SEVERITY, ALERT_STATUS, ALERT_STATUS_ACTIVE, + ALERT_URL, ALERT_UUID, ALERT_WORKFLOW_STATUS, EVENT_KIND, @@ -318,6 +319,7 @@ export const sampleAlertDocAADNoSortId = ( }, ], }, + [ALERT_URL]: 'http://example.com/docID', }, fields: { someKey: ['someValue'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/threshold.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/threshold.ts index 15f82e84155cbf..399a80f4b91013 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/threshold.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/threshold.ts @@ -13,6 +13,7 @@ import { ALERT_STATUS_ACTIVE, ALERT_WORKFLOW_STATUS, ALERT_RULE_NAMESPACE, + ALERT_URL, ALERT_UUID, ALERT_RULE_TYPE_ID, ALERT_RULE_PRODUCER, @@ -125,6 +126,7 @@ export const sampleThresholdAlert = { interval: '5m', exceptions_list: getListArrayMock(), }) as TypeOfFieldMap), + [ALERT_URL]: 'http://example.com/docID', 'kibana.alert.depth': 1, }, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts index e2650f0a142f21..990523d6ad5f9a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts @@ -65,7 +65,16 @@ export const securityRuleTypeFieldMap = { /* eslint-disable complexity */ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = - ({ lists, logger, config, ruleDataClient, ruleExecutionLoggerFactory, version, isPreview }) => + ({ + lists, + logger, + config, + publicBaseUrl, + ruleDataClient, + ruleExecutionLoggerFactory, + version, + isPreview, + }) => (type) => { const { alertIgnoreFields: ignoreFields, alertMergeStrategy: mergeStrategy } = config; const persistenceRuleType = createPersistenceRuleTypeWrapper({ ruleDataClient, logger }); @@ -319,6 +328,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = spaceId, indicesToQuery: inputIndex, alertTimestampOverride, + publicBaseUrl, ruleExecutionLogger, }); @@ -328,6 +338,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = mergeStrategy, completeRule, spaceId, + publicBaseUrl, indicesToQuery: inputIndex, alertTimestampOverride, }); @@ -371,6 +382,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = alertTimestampOverride, alertWithSuppression, refreshOnIndexingAlerts: refresh, + publicBaseUrl, }, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.test.ts index 98c5637b59d7af..b3cf8f3ed16754 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ALERT_RULE_CONSUMER } from '@kbn/rule-data-utils'; +import { ALERT_RULE_CONSUMER, ALERT_URL } from '@kbn/rule-data-utils'; import { sampleDocNoSortId, sampleRuleGuid } from '../__mocks__/es_results'; import { @@ -25,6 +25,7 @@ import { } from '../../../../../common/field_maps/field_names'; const SPACE_ID = 'space'; +const PUBLIC_BASE_URL = 'http://testkibanabaseurl.com'; const ruleExecutionLoggerMock = ruleExecutionLogMock.forExecutors.create(); @@ -54,7 +55,8 @@ describe('buildAlert', () => { SPACE_ID, jest.fn(), completeRule.ruleParams.index as string[], - undefined + undefined, + PUBLIC_BASE_URL ); expect(alertGroup.length).toEqual(3); expect(alertGroup[0]).toEqual( @@ -74,6 +76,9 @@ describe('buildAlert', () => { }), }) ); + expect(alertGroup[0]._source[ALERT_URL]).toContain( + 'http://testkibanabaseurl.com/s/space/app/security/alerts/redirect/f2db3574eaf8450e3f4d1cf4f416d70b110b035ae0a7a00026242df07f0a6c90?index=.alerts-security.alerts-space' + ); expect(alertGroup[1]).toEqual( expect.objectContaining({ _source: expect.objectContaining({ @@ -91,6 +96,9 @@ describe('buildAlert', () => { }), }) ); + expect(alertGroup[1]._source[ALERT_URL]).toContain( + 'http://testkibanabaseurl.com/s/space/app/security/alerts/redirect/1dbc416333244efbda833832eb83f13ea5d980a33c2f981ca8d2b35d82a045da?index=.alerts-security.alerts-space' + ); expect(alertGroup[2]).toEqual( expect.objectContaining({ _source: expect.objectContaining({ @@ -128,7 +136,9 @@ describe('buildAlert', () => { }), }) ); - + expect(alertGroup[2]._source[ALERT_URL]).toContain( + 'http://testkibanabaseurl.com/s/space/app/security/alerts/redirect/1b7d06954e74257140f3bf73f139078483f9658fe829fd806cc307fc0388fb23?index=.alerts-security.alerts-space' + ); const groupIds = alertGroup.map((alert) => alert._source[ALERT_GROUP_ID]); for (const groupId of groupIds) { expect(groupId).toEqual(groupIds[0]); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.ts index f03d185c19996a..92c8e4d749a7d5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.ts @@ -5,8 +5,10 @@ * 2.0. */ -import { ALERT_UUID } from '@kbn/rule-data-utils'; +import { ALERT_URL, ALERT_UUID } from '@kbn/rule-data-utils'; +import { getAlertDetailsUrl } from '../../../../../common/utils/alert_detail_path'; +import { DEFAULT_ALERTS_INDEX } from '../../../../../common/constants'; import type { ConfigType } from '../../../../config'; import type { Ancestor, SignalSource, SignalSourceHit } from '../types'; import { buildAlert, buildAncestors, generateAlertId } from '../factories/utils/build_alert'; @@ -43,7 +45,8 @@ export const buildAlertGroupFromSequence = ( spaceId: string | null | undefined, buildReasonMessage: BuildReasonMessage, indicesToQuery: string[], - alertTimestampOverride: Date | undefined + alertTimestampOverride: Date | undefined, + publicBaseUrl?: string ): Array> => { const ancestors: Ancestor[] = sequence.events.flatMap((event) => buildAncestors(event)); if (ancestors.some((ancestor) => ancestor?.rule === completeRule.alertId)) { @@ -65,7 +68,9 @@ export const buildAlertGroupFromSequence = ( buildReasonMessage, indicesToQuery, alertTimestampOverride, - ruleExecutionLogger + ruleExecutionLogger, + 'placeholder-alert-uuid', // This is overriden below + publicBaseUrl ) ); } catch (error) { @@ -96,7 +101,8 @@ export const buildAlertGroupFromSequence = ( spaceId, buildReasonMessage, indicesToQuery, - alertTimestampOverride + alertTimestampOverride, + publicBaseUrl ); const sequenceAlert: WrappedFieldsLatest = { _id: shellAlert[ALERT_UUID], @@ -106,15 +112,26 @@ export const buildAlertGroupFromSequence = ( // Finally, we have the group id from the shell alert so we can convert the BaseFields into EqlBuildingBlocks const wrappedBuildingBlocks = wrappedBaseFields.map( - (block, i): WrappedFieldsLatest => ({ - ...block, - _source: { - ...block._source, - [ALERT_BUILDING_BLOCK_TYPE]: 'default', - [ALERT_GROUP_ID]: shellAlert[ALERT_GROUP_ID], - [ALERT_GROUP_INDEX]: i, - }, - }) + (block, i): WrappedFieldsLatest => { + const alertUrl = getAlertDetailsUrl({ + alertId: block._id, + index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`, + timestamp: block._source['@timestamp'], + basePath: publicBaseUrl, + spaceId, + }); + + return { + ...block, + _source: { + ...block._source, + [ALERT_BUILDING_BLOCK_TYPE]: 'default', + [ALERT_GROUP_ID]: shellAlert[ALERT_GROUP_ID], + [ALERT_GROUP_INDEX]: i, + [ALERT_URL]: alertUrl, + }, + }; + } ); return [...wrappedBuildingBlocks, sequenceAlert]; @@ -126,7 +143,8 @@ export const buildAlertRoot = ( spaceId: string | null | undefined, buildReasonMessage: BuildReasonMessage, indicesToQuery: string[], - alertTimestampOverride: Date | undefined + alertTimestampOverride: Date | undefined, + publicBaseUrl?: string ): EqlShellFieldsLatest => { const mergedAlerts = objectArrayIntersection(wrappedBuildingBlocks.map((alert) => alert._source)); const reason = buildReasonMessage({ @@ -140,14 +158,25 @@ export const buildAlertRoot = ( spaceId, reason, indicesToQuery, + 'placeholder-uuid', // These will be overriden below + publicBaseUrl, // Not necessary now, but when the ID is created ahead of time this can be passed alertTimestampOverride ); const alertId = generateAlertId(doc); + const alertUrl = getAlertDetailsUrl({ + alertId, + index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`, + timestamp: doc['@timestamp'], + basePath: publicBaseUrl, + spaceId, + }); + return { ...mergedAlerts, ...doc, [ALERT_UUID]: alertId, [ALERT_GROUP_ID]: alertId, + [ALERT_URL]: alertUrl, }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/wrap_sequences_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/wrap_sequences_factory.ts index 650cfb21a71c93..6c608da5cb5cbb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/wrap_sequences_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/wrap_sequences_factory.ts @@ -21,6 +21,7 @@ export const wrapSequencesFactory = completeRule, ignoreFields, mergeStrategy, + publicBaseUrl, spaceId, indicesToQuery, alertTimestampOverride, @@ -32,6 +33,7 @@ export const wrapSequencesFactory = spaceId: string | null | undefined; indicesToQuery: string[]; alertTimestampOverride: Date | undefined; + publicBaseUrl: string | undefined; }): WrapSequences => (sequences, buildReasonMessage) => sequences.reduce( @@ -45,7 +47,8 @@ export const wrapSequencesFactory = spaceId, buildReasonMessage, indicesToQuery, - alertTimestampOverride + alertTimestampOverride, + publicBaseUrl ), ], [] diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts index 2b6702f591ab42..8525c63ce8c871 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts @@ -17,6 +17,7 @@ import { ALERT_SEVERITY, ALERT_STATUS, ALERT_STATUS_ACTIVE, + ALERT_URL, ALERT_UUID, ALERT_WORKFLOW_STATUS, EVENT_ACTION, @@ -31,7 +32,7 @@ import { sampleDocNoSortIdWithTimestamp } from '../../__mocks__/es_results'; import { buildAlert, buildParent, buildAncestors, additionalAlertFields } from './build_alert'; import type { Ancestor, SignalSourceHit } from '../../types'; import { getListArrayMock } from '../../../../../../common/detection_engine/schemas/types/lists.mock'; -import { SERVER_APP_ID } from '../../../../../../common/constants'; +import { DEFAULT_ALERTS_INDEX, SERVER_APP_ID } from '../../../../../../common/constants'; import { EVENT_DATASET } from '../../../../../../common/cti/constants'; import { ALERT_ANCESTORS, @@ -48,6 +49,9 @@ type SignalDoc = SignalSourceHit & { }; const SPACE_ID = 'space'; +const reason = 'alert reasonable reason'; +const publicBaseUrl = 'testKibanaBasePath.com'; +const alertUuid = 'test-uuid'; describe('buildAlert', () => { beforeEach(() => { @@ -58,7 +62,6 @@ describe('buildAlert', () => { const doc = sampleDocNoSortIdWithTimestamp('d5e8eb51-a6a0-456d-8a15-4b79bfec3d71'); delete doc._source.event; const completeRule = getCompleteRuleMock(getQueryRuleParams()); - const reason = 'alert reasonable reason'; const alert = { ...buildAlert( [doc], @@ -66,11 +69,14 @@ describe('buildAlert', () => { SPACE_ID, reason, completeRule.ruleParams.index as string[], + alertUuid, + publicBaseUrl, undefined ), ...additionalAlertFields(doc), }; const timestamp = alert[TIMESTAMP]; + const expectedAlertUrl = `${publicBaseUrl}/s/${SPACE_ID}/app/security/alerts/redirect/${alertUuid}?index=${DEFAULT_ALERTS_INDEX}-${SPACE_ID}×tamp=${timestamp}`; const expected = { [TIMESTAMP]: timestamp, [EVENT_KIND]: 'signal', @@ -222,6 +228,8 @@ describe('buildAlert', () => { timeline_title: 'some-timeline-title', }), [ALERT_DEPTH]: 1, + [ALERT_URL]: expectedAlertUrl, + [ALERT_UUID]: alertUuid, }; expect(alert).toEqual(expected); }); @@ -239,7 +247,6 @@ describe('buildAlert', () => { }, }; const completeRule = getCompleteRuleMock(getQueryRuleParams()); - const reason = 'alert reasonable reason'; const alert = { ...buildAlert( [doc], @@ -247,12 +254,14 @@ describe('buildAlert', () => { SPACE_ID, reason, completeRule.ruleParams.index as string[], + alertUuid, + publicBaseUrl, undefined ), ...additionalAlertFields(doc), }; const timestamp = alert[TIMESTAMP]; - + const expectedAlertUrl = `${publicBaseUrl}/s/${SPACE_ID}/app/security/alerts/redirect/${alertUuid}?index=${DEFAULT_ALERTS_INDEX}-${SPACE_ID}×tamp=${timestamp}`; const expected = { [TIMESTAMP]: timestamp, [EVENT_KIND]: 'signal', @@ -410,6 +419,8 @@ describe('buildAlert', () => { timeline_title: 'some-timeline-title', }), [ALERT_DEPTH]: 1, + [ALERT_URL]: expectedAlertUrl, + [ALERT_UUID]: alertUuid, }; expect(alert).toEqual(expected); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts index d206fa06704f02..5d53380a736f5e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts @@ -34,6 +34,8 @@ import { ALERT_SEVERITY, ALERT_STATUS, ALERT_STATUS_ACTIVE, + ALERT_URL, + ALERT_UUID, ALERT_WORKFLOW_STATUS, EVENT_KIND, SPACE_IDS, @@ -43,6 +45,7 @@ import { flattenWithPrefix } from '@kbn/securitysolution-rules'; import { createHash } from 'crypto'; +import { getAlertDetailsUrl } from '../../../../../../common/utils/alert_detail_path'; import type { BaseSignalHit, SimpleHit } from '../../types'; import type { ThresholdResult } from '../../threshold/types'; import { @@ -51,7 +54,7 @@ import { isWrappedDetectionAlert, isWrappedSignalHit, } from '../../utils/utils'; -import { SERVER_APP_ID } from '../../../../../../common/constants'; +import { DEFAULT_ALERTS_INDEX, SERVER_APP_ID } from '../../../../../../common/constants'; import type { SearchTypes } from '../../../../telemetry/types'; import { ALERT_ANCESTORS, @@ -137,6 +140,8 @@ export const buildAlert = ( spaceId: string | null | undefined, reason: string, indicesToQuery: string[], + alertUuid: string, + publicBaseUrl: string | undefined, alertTimestampOverride: Date | undefined, overrides?: { nameOverride: string; @@ -180,8 +185,18 @@ export const buildAlert = ( primaryTimestamp: TIMESTAMP, }); + const timestamp = alertTimestampOverride?.toISOString() ?? new Date().toISOString(); + + const alertUrl = getAlertDetailsUrl({ + alertId: alertUuid, + index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`, + timestamp, + basePath: publicBaseUrl, + spaceId, + }); + return { - [TIMESTAMP]: alertTimestampOverride?.toISOString() ?? new Date().toISOString(), + [TIMESTAMP]: timestamp, [SPACE_IDS]: spaceId != null ? [spaceId] : [], [EVENT_KIND]: 'signal', [ALERT_ORIGINAL_TIME]: originalTime?.toISOString(), @@ -229,6 +244,8 @@ export const buildAlert = ( [ALERT_RULE_UPDATED_BY]: updatedBy ?? '', [ALERT_RULE_UUID]: completeRule.alertId, [ALERT_RULE_VERSION]: params.version, + [ALERT_URL]: alertUrl, + [ALERT_UUID]: alertUuid, ...flattenWithPrefix(ALERT_RULE_META, params.meta), // These fields don't exist in the mappings, but leaving here for now to limit changes to the alert building logic 'kibana.alert.rule.risk_score': params.riskScore, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts index cd7351362db0b0..1963837d64bc7c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts @@ -55,7 +55,9 @@ export const buildBulkBody = ( buildReasonMessage: BuildReasonMessage, indicesToQuery: string[], alertTimestampOverride: Date | undefined, - ruleExecutionLogger: IRuleExecutionLogForExecutors + ruleExecutionLogger: IRuleExecutionLogForExecutors, + alertUuid: string, + publicBaseUrl?: string ): BaseFieldsLatest => { const mergedDoc = getMergeStrategy(mergeStrategy)({ doc, ignoreFields }); @@ -111,6 +113,8 @@ export const buildBulkBody = ( spaceId, reason, indicesToQuery, + alertUuid, + publicBaseUrl, alertTimestampOverride, overrides ), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts index aae7501bf37987..a5b56303c603df 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts @@ -6,8 +6,6 @@ */ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { ALERT_UUID } from '@kbn/rule-data-utils'; - import type { ConfigType } from '../../../../config'; import type { SignalSource, SimpleHit } from '../types'; import type { CompleteRule, RuleParams } from '../../rule_schema'; @@ -28,6 +26,7 @@ export const wrapHitsFactory = spaceId, indicesToQuery, alertTimestampOverride, + publicBaseUrl, ruleExecutionLogger, }: { completeRule: CompleteRule; @@ -36,6 +35,7 @@ export const wrapHitsFactory = spaceId: string | null | undefined; indicesToQuery: string[]; alertTimestampOverride: Date | undefined; + publicBaseUrl: string | undefined; ruleExecutionLogger: IRuleExecutionLogForExecutors; }) => ( @@ -49,23 +49,27 @@ export const wrapHitsFactory = String(event._version), `${spaceId}:${completeRule.alertId}` ); + + const baseAlert = buildBulkBody( + spaceId, + completeRule, + event as SimpleHit, + mergeStrategy, + ignoreFields, + true, + buildReasonMessage, + indicesToQuery, + alertTimestampOverride, + ruleExecutionLogger, + id, + publicBaseUrl + ); + return { _id: id, _index: '', _source: { - ...buildBulkBody( - spaceId, - completeRule, - event as SimpleHit, - mergeStrategy, - ignoreFields, - true, - buildReasonMessage, - indicesToQuery, - alertTimestampOverride, - ruleExecutionLogger - ), - [ALERT_UUID]: id, + ...baseAlert, }, }; }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts index c4525de1b00b35..41c5420c748e97 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts @@ -107,6 +107,7 @@ export const createNewTermsAlertType = ( exceptionFilter, unprocessedExceptions, alertTimestampOverride, + publicBaseUrl, }, services, params, @@ -300,6 +301,7 @@ export const createNewTermsAlertType = ( indicesToQuery: inputIndex, alertTimestampOverride, ruleExecutionLogger, + publicBaseUrl, }); const bulkCreateResult = await bulkCreate( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.test.ts index 69d0b90b45c29b..67a3c69af98507 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ALERT_UUID } from '@kbn/rule-data-utils'; +import { ALERT_URL, ALERT_UUID } from '@kbn/rule-data-utils'; import { ALERT_NEW_TERMS } from '../../../../../common/field_maps/field_names'; import { getCompleteRuleMock, getNewTermsRuleParams } from '../../rule_schema/mocks'; import { ruleExecutionLogMock } from '../../rule_monitoring/mocks'; @@ -15,6 +15,7 @@ import { wrapNewTermsAlerts } from './wrap_new_terms_alerts'; const ruleExecutionLogger = ruleExecutionLogMock.forExecutors.create(); const docId = 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71'; +const publicBaseUrl = 'http://somekibanabaseurl.com'; describe('wrapNewTermsAlerts', () => { test('should create an alert with the correct _id from a document', () => { const doc = sampleDocNoSortIdWithTimestamp(docId); @@ -27,11 +28,15 @@ describe('wrapNewTermsAlerts', () => { indicesToQuery: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], alertTimestampOverride: undefined, ruleExecutionLogger, + publicBaseUrl, }); expect(alerts[0]._id).toEqual('a36d9fe6fe4b2f65058fb1a487733275f811af58'); expect(alerts[0]._source[ALERT_UUID]).toEqual('a36d9fe6fe4b2f65058fb1a487733275f811af58'); expect(alerts[0]._source[ALERT_NEW_TERMS]).toEqual(['127.0.0.1']); + expect(alerts[0]._source[ALERT_URL]).toContain( + 'http://somekibanabaseurl.com/app/security/alerts/redirect/a36d9fe6fe4b2f65058fb1a487733275f811af58?index=.alerts-security.alerts-default' + ); }); test('should create an alert with a different _id if the space is different', () => { @@ -45,11 +50,15 @@ describe('wrapNewTermsAlerts', () => { indicesToQuery: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], alertTimestampOverride: undefined, ruleExecutionLogger, + publicBaseUrl, }); expect(alerts[0]._id).toEqual('f7877a31b1cc83373dbc9ba5939ebfab1db66545'); expect(alerts[0]._source[ALERT_UUID]).toEqual('f7877a31b1cc83373dbc9ba5939ebfab1db66545'); expect(alerts[0]._source[ALERT_NEW_TERMS]).toEqual(['127.0.0.1']); + expect(alerts[0]._source[ALERT_URL]).toContain( + 'http://somekibanabaseurl.com/s/otherSpace/app/security/alerts/redirect/f7877a31b1cc83373dbc9ba5939ebfab1db66545?index=.alerts-security.alerts-otherSpace' + ); }); test('should create an alert with a different _id if the newTerms array is different', () => { @@ -63,11 +72,15 @@ describe('wrapNewTermsAlerts', () => { indicesToQuery: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], alertTimestampOverride: undefined, ruleExecutionLogger, + publicBaseUrl, }); expect(alerts[0]._id).toEqual('75e5a507a4bc48bcd983820c7fd2d9621ff4e2ea'); expect(alerts[0]._source[ALERT_UUID]).toEqual('75e5a507a4bc48bcd983820c7fd2d9621ff4e2ea'); expect(alerts[0]._source[ALERT_NEW_TERMS]).toEqual(['127.0.0.2']); + expect(alerts[0]._source[ALERT_URL]).toContain( + 'http://somekibanabaseurl.com/s/otherSpace/app/security/alerts/redirect/75e5a507a4bc48bcd983820c7fd2d9621ff4e2ea?index=.alerts-security.alerts-otherSpace' + ); }); test('should create an alert with a different _id if the newTerms array contains multiple terms', () => { @@ -81,10 +94,14 @@ describe('wrapNewTermsAlerts', () => { indicesToQuery: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], alertTimestampOverride: undefined, ruleExecutionLogger, + publicBaseUrl, }); expect(alerts[0]._id).toEqual('86a216cfa4884767d9bb26d2b8db911cb4aa85ce'); expect(alerts[0]._source[ALERT_UUID]).toEqual('86a216cfa4884767d9bb26d2b8db911cb4aa85ce'); expect(alerts[0]._source[ALERT_NEW_TERMS]).toEqual(['127.0.0.1', '127.0.0.2']); + expect(alerts[0]._source[ALERT_URL]).toContain( + 'http://somekibanabaseurl.com/s/otherSpace/app/security/alerts/redirect/86a216cfa4884767d9bb26d2b8db911cb4aa85ce?index=.alerts-security.alerts-otherSpace' + ); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.ts index 424c52273c30ab..2a373edf7de6e0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.ts @@ -7,7 +7,6 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/types'; import objectHash from 'object-hash'; -import { ALERT_UUID } from '@kbn/rule-data-utils'; import type { BaseFieldsLatest, NewTermsFieldsLatest, @@ -34,6 +33,7 @@ export const wrapNewTermsAlerts = ({ indicesToQuery, alertTimestampOverride, ruleExecutionLogger, + publicBaseUrl, }: { eventsAndTerms: EventsAndTerms[]; spaceId: string | null | undefined; @@ -42,6 +42,7 @@ export const wrapNewTermsAlerts = ({ indicesToQuery: string[]; alertTimestampOverride: Date | undefined; ruleExecutionLogger: IRuleExecutionLogForExecutors; + publicBaseUrl: string | undefined; }): Array> => { return eventsAndTerms.map((eventAndTerms) => { const id = objectHash([ @@ -61,15 +62,17 @@ export const wrapNewTermsAlerts = ({ buildReasonMessageForNewTermsAlert, indicesToQuery, alertTimestampOverride, - ruleExecutionLogger + ruleExecutionLogger, + id, + publicBaseUrl ); + return { _id: id, _index: '', _source: { ...baseAlert, [ALERT_NEW_TERMS]: eventAndTerms.newTerms, - [ALERT_UUID]: id, }, }; }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts index 99f1901aad9f82..6338917eecbb97 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts @@ -224,6 +224,7 @@ export const groupAndBulkCreate = async ({ completeRule: runOpts.completeRule, mergeStrategy: runOpts.mergeStrategy, indicesToQuery: runOpts.inputIndex, + publicBaseUrl: runOpts.publicBaseUrl, buildReasonMessage, alertTimestampOverride: runOpts.alertTimestampOverride, ruleExecutionLogger: runOpts.ruleExecutionLogger, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/wrap_suppressed_alerts.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/wrap_suppressed_alerts.ts index 42fe0954a18475..c793d24f9fa6b1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/wrap_suppressed_alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/wrap_suppressed_alerts.ts @@ -8,7 +8,6 @@ import objectHash from 'object-hash'; import type * as estypes from '@elastic/elasticsearch/lib/api/types'; import { - ALERT_UUID, ALERT_SUPPRESSION_TERMS, ALERT_SUPPRESSION_DOCS_COUNT, ALERT_SUPPRESSION_END, @@ -56,6 +55,7 @@ export const wrapSuppressedAlerts = ({ buildReasonMessage, alertTimestampOverride, ruleExecutionLogger, + publicBaseUrl, }: { suppressionBuckets: SuppressionBuckets[]; spaceId: string; @@ -65,6 +65,7 @@ export const wrapSuppressedAlerts = ({ buildReasonMessage: BuildReasonMessage; alertTimestampOverride: Date | undefined; ruleExecutionLogger: IRuleExecutionLogForExecutors; + publicBaseUrl: string | undefined; }): Array> => { return suppressionBuckets.map((bucket) => { const id = objectHash([ @@ -91,8 +92,11 @@ export const wrapSuppressedAlerts = ({ buildReasonMessage, indicesToQuery, alertTimestampOverride, - ruleExecutionLogger + ruleExecutionLogger, + id, + publicBaseUrl ); + return { _id: id, _index: '', @@ -102,7 +106,6 @@ export const wrapSuppressedAlerts = ({ [ALERT_SUPPRESSION_START]: bucket.start, [ALERT_SUPPRESSION_END]: bucket.end, [ALERT_SUPPRESSION_DOCS_COUNT]: bucket.count - 1, - [ALERT_UUID]: id, [ALERT_INSTANCE_ID]: instanceId, }, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts index d3f8e8b42b44e3..13c7e9e53df54a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts @@ -34,6 +34,7 @@ jest.mock('../utils/get_list_client', () => ({ describe('Custom Query Alerts', () => { const mocks = createRuleTypeMocks(); const licensing = licensingMock.createSetup(); + const publicBaseUrl = 'http://somekibanabaseurl.com'; const { dependencies, executor, services } = mocks; const { alerting, lists, logger, ruleDataClient } = dependencies; @@ -44,6 +45,7 @@ describe('Custom Query Alerts', () => { ruleDataClient, ruleExecutionLoggerFactory: () => Promise.resolve(ruleExecutionLogMock.forExecutors.create()), version: '8.3', + publicBaseUrl, }); const eventsTelemetry = createMockTelemetryEventsSender(true); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts index 0dee5eba79cc4f..c58613344f7247 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts @@ -100,6 +100,7 @@ export interface RunOpts { alertTimestampOverride: Date | undefined; alertWithSuppression: SuppressedAlertService; refreshOnIndexingAlerts: RefreshTypes; + publicBaseUrl: string | undefined; } export type SecurityAlertType< @@ -129,6 +130,7 @@ export interface CreateSecurityRuleTypeWrapperProps { lists: SetupPlugins['lists']; logger: Logger; config: ConfigType; + publicBaseUrl: string | undefined; ruleDataClient: IRuleDataClient; ruleExecutionLoggerFactory: IRuleExecutionLogService['createClientForExecutors']; version: string; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts index 43cd37aca396a9..3d3dc8872f261b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts @@ -38,6 +38,7 @@ import { ALERT_SEVERITY, ALERT_STATUS, ALERT_STATUS_ACTIVE, + ALERT_URL, ALERT_UUID, ALERT_WORKFLOW_STATUS, EVENT_KIND, @@ -158,6 +159,7 @@ export const createAlert = ( [ALERT_RULE_UUID]: '2e051244-b3c6-4779-a241-e1b4f0beceb9', [ALERT_RULE_VERSION]: 1, [ALERT_UUID]: someUuid, + [ALERT_URL]: `http://kibanaurl.com/app/security/alerts/redirect/${someUuid}?index=myFakeSignalIndex×tamp=2020-04-20T21:27:45`, 'kibana.alert.rule.risk_score': 50, 'kibana.alert.rule.severity': 'high', 'kibana.alert.rule.building_block_type': undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create.test.ts index 8e71a4dce49aaf..31c1e38b08f917 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create.test.ts @@ -112,6 +112,7 @@ describe('searchAfterAndBulkCreate', () => { indicesToQuery: inputIndexPattern, alertTimestampOverride: undefined, ruleExecutionLogger, + publicBaseUrl: 'http://testkibanabaseurl.com', }); }); diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 48b963b788bd28..164969377c6d77 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -238,6 +238,7 @@ export class Plugin implements ISecuritySolutionPlugin { lists: plugins.lists, logger: this.logger, config: this.config, + publicBaseUrl: core.http.basePath.publicBaseUrl, ruleDataClient, ruleExecutionLoggerFactory: ruleExecutionLogService.createClientForExecutors, version: pluginContext.env.packageInfo.version, From 60fedce9bdfa8ef30a7f828fbc98b814a5c990ab Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Tue, 25 Apr 2023 09:01:06 -0600 Subject: [PATCH 12/13] [Dashboard] [Controls] Fix unsaved changes bug on empty dashboard (#155648) ## Summary ### Before When loading a dashboard with no panels but at least one control, the `diffingMiddleware` was not being fired - this caused a bug where making selections in the control (which were not saved into the dashboard) would not trigger unsaved changes on reload/refresh: https://user-images.githubusercontent.com/8698078/234059128-d94b5656-a7f7-4a14-bb32-d29065b0a475.mov ### After This was because, when the dashboard had no children, no Redux state changes were being dispatched to the Dashboard container which meant that the middleware was never triggered - this is fixed by adding `startWith(null)` ([reference](https://rxjs.dev/api/index/function/startWith)) to the `checkForUnsavedChangesSubject$` subscription so that it always fires **on load**. https://user-images.githubusercontent.com/8698078/234066255-7f44621e-0631-4195-a593-351a14a59860.mov ### Checklist - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../state/diffing/dashboard_diffing_integration.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts index 02399543750cdd..f91cfe51fe739c 100644 --- a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts +++ b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts @@ -7,7 +7,7 @@ */ import { omit } from 'lodash'; import { AnyAction, Middleware } from 'redux'; -import { debounceTime, Observable, Subject, switchMap } from 'rxjs'; +import { debounceTime, Observable, startWith, Subject, switchMap } from 'rxjs'; import { DashboardContainerInput } from '../../../../common'; import type { DashboardDiffFunctions } from './dashboard_diffing_functions'; @@ -90,6 +90,7 @@ export function startDiffingDashboardState( this.subscriptions.add( checkForUnsavedChangesSubject$ .pipe( + startWith(null), debounceTime(CHANGE_CHECK_DEBOUNCE), switchMap(() => { return new Observable((observer) => { From 8c1008fba82fb7a0063ad03ad0736c343ff65b8d Mon Sep 17 00:00:00 2001 From: Jatin Kathuria Date: Tue, 25 Apr 2023 17:01:35 +0200 Subject: [PATCH 13/13] [Security solution] Mocks and Unit tests for Alert Page controls + Handles #155414 and #155481 (#155508) ## Summary This PR - Adds unit tests and mocks for the Alert Page Controls. - Adds feature - [Security Solution] New Alert Page Controls should have a blank placeholder + only string controls #155414 1. For newly added controls placeholder should be blank instead of `Any` 2. Do not allow number fields to be added as controls. | Before | After | |--|--| |