diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index dea2c12756b08..9e31bd31b4037 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -66,6 +66,7 @@ # APM /x-pack/plugins/apm/ @elastic/apm-ui /x-pack/test/functional/apps/apm/ @elastic/apm-ui +/x-pack/test/apm_api_integration/ @elastic/apm-ui /src/plugins/apm_oss/ @elastic/apm-ui /src/apm.js @elastic/kibana-core @vigneshshanmugam /packages/kbn-apm-config-loader/ @elastic/kibana-core @vigneshshanmugam @@ -80,6 +81,7 @@ /x-pack/plugins/apm/server/lib/rum_client @elastic/uptime /x-pack/plugins/apm/server/routes/rum_client.ts @elastic/uptime /x-pack/plugins/apm/server/projections/rum_page_load_transactions.ts @elastic/uptime +/x-pack/test/apm_api_integration/tests/csm/ @elastic/uptime # Beats /x-pack/plugins/beats_management/ @elastic/beats @@ -99,7 +101,7 @@ # Observability UIs /x-pack/plugins/infra/ @elastic/logs-metrics-ui -/x-pack/plugins/fleet/ @elastic/ingest-management +/x-pack/plugins/fleet/ @elastic/fleet /x-pack/plugins/observability/ @elastic/observability-ui /x-pack/plugins/monitoring/ @elastic/stack-monitoring-ui /x-pack/plugins/uptime @elastic/uptime diff --git a/.github/paths-labeller.yml b/.github/paths-labeller.yml index f74870578ecb1..81d57be9b2d95 100644 --- a/.github/paths-labeller.yml +++ b/.github/paths-labeller.yml @@ -10,7 +10,7 @@ - "src/plugins/bfetch/**/*.*" - "Team:apm": - "x-pack/plugins/apm/**/*.*" - - "Team:Ingest Management": + - "Team:Fleet": - "x-pack/plugins/fleet/**/*.*" - "x-pack/test/fleet_api_integration/**/*.*" - "Team:uptime": diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createindexaliasnotfounderror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createindexaliasnotfounderror.md new file mode 100644 index 0000000000000..2b897db7bba4c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createindexaliasnotfounderror.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [createIndexAliasNotFoundError](./kibana-plugin-core-server.savedobjectserrorhelpers.createindexaliasnotfounderror.md) + +## SavedObjectsErrorHelpers.createIndexAliasNotFoundError() method + +Signature: + +```typescript +static createIndexAliasNotFoundError(alias: string): DecoratedError; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| alias | string | | + +Returns: + +`DecoratedError` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.decorateindexaliasnotfounderror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.decorateindexaliasnotfounderror.md new file mode 100644 index 0000000000000..c7e10fc42ead1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.decorateindexaliasnotfounderror.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [decorateIndexAliasNotFoundError](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateindexaliasnotfounderror.md) + +## SavedObjectsErrorHelpers.decorateIndexAliasNotFoundError() method + +Signature: + +```typescript +static decorateIndexAliasNotFoundError(error: Error, alias: string): DecoratedError; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| error | Error | | +| alias | string | | + +Returns: + +`DecoratedError` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.isgeneralerror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.isgeneralerror.md new file mode 100644 index 0000000000000..4b4ede2f77a7e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.isgeneralerror.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [isGeneralError](./kibana-plugin-core-server.savedobjectserrorhelpers.isgeneralerror.md) + +## SavedObjectsErrorHelpers.isGeneralError() method + +Signature: + +```typescript +static isGeneralError(error: Error | DecoratedError): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| error | Error | DecoratedError | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md index 9b69012ed5f12..2dc78f2df3a83 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md @@ -18,6 +18,7 @@ export declare class SavedObjectsErrorHelpers | [createBadRequestError(reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.createbadrequesterror.md) | static | | | [createConflictError(type, id, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md) | static | | | [createGenericNotFoundError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.creategenericnotfounderror.md) | static | | +| [createIndexAliasNotFoundError(alias)](./kibana-plugin-core-server.savedobjectserrorhelpers.createindexaliasnotfounderror.md) | static | | | [createInvalidVersionError(versionInput)](./kibana-plugin-core-server.savedobjectserrorhelpers.createinvalidversionerror.md) | static | | | [createTooManyRequestsError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.createtoomanyrequestserror.md) | static | | | [createUnsupportedTypeError(type)](./kibana-plugin-core-server.savedobjectserrorhelpers.createunsupportedtypeerror.md) | static | | @@ -27,6 +28,7 @@ export declare class SavedObjectsErrorHelpers | [decorateEsUnavailableError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateesunavailableerror.md) | static | | | [decorateForbiddenError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateforbiddenerror.md) | static | | | [decorateGeneralError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorategeneralerror.md) | static | | +| [decorateIndexAliasNotFoundError(error, alias)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateindexaliasnotfounderror.md) | static | | | [decorateNotAuthorizedError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decoratenotauthorizederror.md) | static | | | [decorateRequestEntityTooLargeError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decoraterequestentitytoolargeerror.md) | static | | | [decorateTooManyRequestsError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decoratetoomanyrequestserror.md) | static | | @@ -35,6 +37,7 @@ export declare class SavedObjectsErrorHelpers | [isEsCannotExecuteScriptError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isescannotexecutescripterror.md) | static | | | [isEsUnavailableError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isesunavailableerror.md) | static | | | [isForbiddenError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isforbiddenerror.md) | static | | +| [isGeneralError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isgeneralerror.md) | static | | | [isInvalidVersionError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isinvalidversionerror.md) | static | | | [isNotAuthorizedError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isnotauthorizederror.md) | static | | | [isNotFoundError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isnotfounderror.md) | static | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md index b5ac4a4e53887..5f8966f0227ac 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md @@ -7,14 +7,14 @@ Signature: ```typescript -protected handleSearchError(e: any, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; +protected handleSearchError(e: KibanaServerError | AbortError, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| e | any | | +| e | KibanaServerError | AbortError | | | timeoutSignal | AbortSignal | | | options | ISearchOptions | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md index 8fd17e6b1a1d9..e96fe8b8e08dc 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md @@ -4,8 +4,12 @@ ## SearchSource.fetch() method -Fetch this source and reject the returned Promise on error +> Warning: This API is now obsolete. +> +> Use fetch$ instead +> +Fetch this source and reject the returned Promise on error Signature: diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md new file mode 100644 index 0000000000000..bcf220a9a27e6 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchSource](./kibana-plugin-plugins-data-public.searchsource.md) > [fetch$](./kibana-plugin-plugins-data-public.searchsource.fetch_.md) + +## SearchSource.fetch$() method + +Fetch this source from Elasticsearch, returning an observable over the response(s) + +Signature: + +```typescript +fetch$(options?: ISearchOptions): import("rxjs").Observable>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| options | ISearchOptions | | + +Returns: + +`import("rxjs").Observable>` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md index df302e9f3b0d3..2af9cc14e3668 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md @@ -33,6 +33,7 @@ export declare class SearchSource | [createCopy()](./kibana-plugin-plugins-data-public.searchsource.createcopy.md) | | creates a copy of this search source (without its children) | | [destroy()](./kibana-plugin-plugins-data-public.searchsource.destroy.md) | | Completely destroy the SearchSource. {undefined} | | [fetch(options)](./kibana-plugin-plugins-data-public.searchsource.fetch.md) | | Fetch this source and reject the returned Promise on error | +| [fetch$(options)](./kibana-plugin-plugins-data-public.searchsource.fetch_.md) | | Fetch this source from Elasticsearch, returning an observable over the response(s) | | [getField(field, recurse)](./kibana-plugin-plugins-data-public.searchsource.getfield.md) | | Gets a single field from the fields | | [getFields()](./kibana-plugin-plugins-data-public.searchsource.getfields.md) | | returns all search source fields | | [getId()](./kibana-plugin-plugins-data-public.searchsource.getid.md) | | returns search source id | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchtimeouterror._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchtimeouterror._constructor_.md index 1c6370c7d0356..b4eecca665e82 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchtimeouterror._constructor_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchtimeouterror._constructor_.md @@ -9,13 +9,13 @@ Constructs a new instance of the `SearchTimeoutError` class Signature: ```typescript -constructor(err: Error, mode: TimeoutErrorMode); +constructor(err: Record, mode: TimeoutErrorMode); ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| err | Error | | +| err | Record<string, any> | | | mode | TimeoutErrorMode | | diff --git a/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts b/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts index 56c75c5aca419..6272d6ba00ee8 100644 --- a/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts +++ b/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts @@ -25,5 +25,6 @@ export async function emptyKibanaIndexAction({ await cleanKibanaIndices({ client, stats, log, kibanaPluginIds }); await migrateKibanaIndex({ client, kbnClient }); - return stats; + stats.createdIndex('.kibana'); + return stats.toJSON(); } diff --git a/packages/kbn-es-archiver/src/es_archiver.ts b/packages/kbn-es-archiver/src/es_archiver.ts index f101c5d6867f1..8601dedad0e27 100644 --- a/packages/kbn-es-archiver/src/es_archiver.ts +++ b/packages/kbn-es-archiver/src/es_archiver.ts @@ -155,7 +155,7 @@ export class EsArchiver { * @return Promise */ async emptyKibanaIndex() { - await emptyKibanaIndexAction({ + return await emptyKibanaIndexAction({ client: this.client, log: this.log, kbnClient: this.kbnClient, diff --git a/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts b/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts index 0459a4301cf6b..91c0bd8343a36 100644 --- a/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts +++ b/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts @@ -76,7 +76,9 @@ export async function migrateKibanaIndex({ */ async function fetchKibanaIndices(client: Client) { const kibanaIndices = await client.cat.indices({ index: '.kibana*', format: 'json' }); - const isKibanaIndex = (index: string) => /^\.kibana(:?_\d*)?$/.test(index); + const isKibanaIndex = (index: string) => + /^\.kibana(:?_\d*)?$/.test(index) || + /^\.kibana(_task_manager)?_(pre)?\d+\.\d+\.\d+/.test(index); return kibanaIndices.map((x: { index: string }) => x.index).filter(isKibanaIndex); } @@ -103,7 +105,7 @@ export async function cleanKibanaIndices({ while (true) { const resp = await client.deleteByQuery({ - index: `.kibana`, + index: `.kibana,.kibana_task_manager`, body: { query: { bool: { @@ -115,7 +117,7 @@ export async function cleanKibanaIndices({ }, }, }, - ignore: [409], + ignore: [404, 409], }); if (resp.total !== resp.deleted) { diff --git a/packages/kbn-legacy-logging/src/setup_logging.test.ts b/packages/kbn-legacy-logging/src/setup_logging.test.ts new file mode 100644 index 0000000000000..6386b400329b9 --- /dev/null +++ b/packages/kbn-legacy-logging/src/setup_logging.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { Server } from '@hapi/hapi'; +import { reconfigureLogging, setupLogging } from './setup_logging'; +import { LegacyLoggingConfig } from './schema'; + +describe('reconfigureLogging', () => { + test(`doesn't throw an error`, () => { + const server = new Server(); + const config: LegacyLoggingConfig = { + silent: false, + quiet: false, + verbose: true, + events: {}, + dest: '/tmp/foo', + filter: {}, + json: true, + rotate: { + enabled: false, + everyBytes: 0, + keepFiles: 0, + pollingInterval: 0, + usePolling: false, + }, + }; + setupLogging(server, config, 10); + reconfigureLogging(server, { ...config, dest: '/tmp/bar' }, 0); + }); +}); diff --git a/packages/kbn-legacy-logging/src/setup_logging.ts b/packages/kbn-legacy-logging/src/setup_logging.ts index 4370e4ab77d68..ffe3be558f366 100644 --- a/packages/kbn-legacy-logging/src/setup_logging.ts +++ b/packages/kbn-legacy-logging/src/setup_logging.ts @@ -37,5 +37,5 @@ export function reconfigureLogging( opsInterval: number ) { const loggingOptions = getLoggingConfiguration(config, opsInterval); - (server.plugins as any)['@elastic/good'].reconfigure(loggingOptions); + (server.plugins as any).good.reconfigure(loggingOptions); } diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index a13976d148738..794503656ba04 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -34,7 +34,7 @@ pageLoadAssetSize: indexLifecycleManagement: 107090 indexManagement: 140608 indexPatternManagement: 154222 - infra: 197873 + infra: 204800 fleet: 415829 ingestPipelines: 58003 inputControlVis: 172675 diff --git a/src/core/server/saved_objects/migrationsv2/model.test.ts b/src/core/server/saved_objects/migrationsv2/model.test.ts index d5ab85c54a728..a9aa69960b1c2 100644 --- a/src/core/server/saved_objects/migrationsv2/model.test.ts +++ b/src/core/server/saved_objects/migrationsv2/model.test.ts @@ -182,6 +182,21 @@ describe('migrations v2 model', () => { versionAlias: '.kibana_7.11.0', versionIndex: '.kibana_7.11.0_001', }; + const mappingsWithUnknownType = { + properties: { + disabled_saved_object_type: { + properties: { + value: { type: 'keyword' }, + }, + }, + }, + _meta: { + migrationMappingPropertyHashes: { + disabled_saved_object_type: '7997cf5a56cc02bdc9c93361bde732b0', + }, + }, + }; + test('INIT -> OUTDATED_DOCUMENTS_SEARCH if .kibana is already pointing to the target index', () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana_7.11.0_001': { @@ -189,38 +204,27 @@ describe('migrations v2 model', () => { '.kibana': {}, '.kibana_7.11.0': {}, }, - mappings: { - properties: { - disabled_saved_object_type: { - properties: { - value: { type: 'keyword' }, - }, - }, - }, - _meta: { - migrationMappingPropertyHashes: { - disabled_saved_object_type: '7997cf5a56cc02bdc9c93361bde732b0', - }, - }, - }, + mappings: mappingsWithUnknownType, settings: {}, }, }); const newState = model(initState, res); expect(newState.controlState).toEqual('OUTDATED_DOCUMENTS_SEARCH'); + // This snapshot asserts that we merge the + // migrationMappingPropertyHashes of the existing index, but we leave + // the mappings for the disabled_saved_object_type untouched. There + // might be another Kibana instance that knows about this type and + // needs these mappings in place. expect(newState.targetIndexMappings).toMatchInlineSnapshot(` Object { "_meta": Object { "migrationMappingPropertyHashes": Object { + "disabled_saved_object_type": "7997cf5a56cc02bdc9c93361bde732b0", "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", }, }, "properties": Object { - "disabled_saved_object_type": Object { - "dynamic": false, - "properties": Object {}, - }, "new_saved_object_type": Object { "properties": Object { "value": Object { @@ -271,7 +275,7 @@ describe('migrations v2 model', () => { '.kibana': {}, '.kibana_7.12.0': {}, }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + mappings: mappingsWithUnknownType, settings: {}, }, '.kibana_7.11.0_001': { @@ -288,12 +292,37 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('.kibana_7.invalid.0_001'), targetIndex: '.kibana_7.11.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); }); test('INIT -> SET_SOURCE_WRITE_BLOCK when migrating from a v2 migrations index (>= 7.11.0)', () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana_7.11.0_001': { aliases: { '.kibana': {}, '.kibana_7.11.0': {} }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + mappings: mappingsWithUnknownType, settings: {}, }, '.kibana_3': { @@ -319,6 +348,31 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('.kibana_7.11.0_001'), targetIndex: '.kibana_7.12.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -328,7 +382,7 @@ describe('migrations v2 model', () => { aliases: { '.kibana': {}, }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + mappings: mappingsWithUnknownType, settings: {}, }, }); @@ -339,6 +393,31 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('.kibana_3'), targetIndex: '.kibana_7.11.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -346,7 +425,7 @@ describe('migrations v2 model', () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana': { aliases: {}, - mappings: { properties: {}, _meta: {} }, + mappings: mappingsWithUnknownType, settings: {}, }, }); @@ -357,6 +436,31 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('.kibana_pre6.5.0_001'), targetIndex: '.kibana_7.11.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -366,7 +470,7 @@ describe('migrations v2 model', () => { aliases: { 'my-saved-objects': {}, }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + mappings: mappingsWithUnknownType, settings: {}, }, }); @@ -386,6 +490,31 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('my-saved-objects_3'), targetIndex: 'my-saved-objects_7.11.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -395,7 +524,7 @@ describe('migrations v2 model', () => { aliases: { 'my-saved-objects': {}, }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + mappings: mappingsWithUnknownType, settings: {}, }, }); @@ -416,6 +545,31 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('my-saved-objects_7.11.0'), targetIndex: 'my-saved-objects_7.12.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); diff --git a/src/core/server/saved_objects/migrationsv2/model.ts b/src/core/server/saved_objects/migrationsv2/model.ts index 1119edde8e268..3556bb611bb67 100644 --- a/src/core/server/saved_objects/migrationsv2/model.ts +++ b/src/core/server/saved_objects/migrationsv2/model.ts @@ -60,13 +60,13 @@ function throwBadResponse(state: State, res: any): never { * Merge the _meta.migrationMappingPropertyHashes mappings of an index with * the given target mappings. * - * @remarks Mapping updates are commutative (deeply merged) by Elasticsearch, - * except for the _meta key. The source index we're migrating from might - * contain documents created by a plugin that is disabled in the Kibana - * instance performing this migration. We merge the - * _meta.migrationMappingPropertyHashes mappings from the source index into - * the targetMappings to ensure that any `migrationPropertyHashes` for - * disabled plugins aren't lost. + * @remarks When another instance already completed a migration, the existing + * target index might contain documents and mappings created by a plugin that + * is disabled in the current Kibana instance performing this migration. + * Mapping updates are commutative (deeply merged) by Elasticsearch, except + * for the `_meta` key. By merging the `_meta.migrationMappingPropertyHashes` + * mappings from the existing target index index into the targetMappings we + * ensure that any `migrationPropertyHashes` for disabled plugins aren't lost. * * Right now we don't use these `migrationPropertyHashes` but it could be used * in the future to detect if mappings were changed. If mappings weren't @@ -209,7 +209,7 @@ export const model = (currentState: State, resW: ResponseType): // index sourceIndex: Option.none, targetIndex: `${stateP.indexPrefix}_${stateP.kibanaVersion}_001`, - targetIndexMappings: disableUnknownTypeMappingFields( + targetIndexMappings: mergeMigrationMappingPropertyHashes( stateP.targetIndexMappings, indices[aliases[stateP.currentAlias]].mappings ), @@ -242,7 +242,7 @@ export const model = (currentState: State, resW: ResponseType): controlState: 'SET_SOURCE_WRITE_BLOCK', sourceIndex: Option.some(source) as Option.Some, targetIndex: target, - targetIndexMappings: mergeMigrationMappingPropertyHashes( + targetIndexMappings: disableUnknownTypeMappingFields( stateP.targetIndexMappings, indices[source].mappings ), @@ -279,7 +279,7 @@ export const model = (currentState: State, resW: ResponseType): controlState: 'LEGACY_SET_WRITE_BLOCK', sourceIndex: Option.some(legacyReindexTarget) as Option.Some, targetIndex: target, - targetIndexMappings: mergeMigrationMappingPropertyHashes( + targetIndexMappings: disableUnknownTypeMappingFields( stateP.targetIndexMappings, indices[stateP.legacyIndex].mappings ), diff --git a/src/core/server/saved_objects/routes/bulk_create.ts b/src/core/server/saved_objects/routes/bulk_create.ts index 6d57eaa3777e6..b85747985e523 100644 --- a/src/core/server/saved_objects/routes/bulk_create.ts +++ b/src/core/server/saved_objects/routes/bulk_create.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -44,7 +45,7 @@ export const registerBulkCreateRoute = (router: IRouter, { coreUsageData }: Rout ), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { overwrite } = req.query; const usageStatsClient = coreUsageData.getClient(); diff --git a/src/core/server/saved_objects/routes/bulk_get.ts b/src/core/server/saved_objects/routes/bulk_get.ts index a260301633668..580bf26a4e529 100644 --- a/src/core/server/saved_objects/routes/bulk_get.ts +++ b/src/core/server/saved_objects/routes/bulk_get.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -28,7 +29,7 @@ export const registerBulkGetRoute = (router: IRouter, { coreUsageData }: RouteDe ), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const usageStatsClient = coreUsageData.getClient(); usageStatsClient.incrementSavedObjectsBulkGet({ request: req }).catch(() => {}); diff --git a/src/core/server/saved_objects/routes/bulk_update.ts b/src/core/server/saved_objects/routes/bulk_update.ts index f9b8d4a2f567f..e592adc72a244 100644 --- a/src/core/server/saved_objects/routes/bulk_update.ts +++ b/src/core/server/saved_objects/routes/bulk_update.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -39,7 +40,7 @@ export const registerBulkUpdateRoute = (router: IRouter, { coreUsageData }: Rout ), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const usageStatsClient = coreUsageData.getClient(); usageStatsClient.incrementSavedObjectsBulkUpdate({ request: req }).catch(() => {}); diff --git a/src/core/server/saved_objects/routes/create.ts b/src/core/server/saved_objects/routes/create.ts index fd256abac3526..f6043ca96398d 100644 --- a/src/core/server/saved_objects/routes/create.ts +++ b/src/core/server/saved_objects/routes/create.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -43,7 +44,7 @@ export const registerCreateRoute = (router: IRouter, { coreUsageData }: RouteDep }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { type, id } = req.params; const { overwrite } = req.query; const { diff --git a/src/core/server/saved_objects/routes/delete.ts b/src/core/server/saved_objects/routes/delete.ts index a7846c3dc845b..b127f64b74a0c 100644 --- a/src/core/server/saved_objects/routes/delete.ts +++ b/src/core/server/saved_objects/routes/delete.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -28,7 +29,7 @@ export const registerDeleteRoute = (router: IRouter, { coreUsageData }: RouteDep }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { type, id } = req.params; const { force } = req.query; diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts index 9b40855afec2e..f064cf1ca0ec1 100644 --- a/src/core/server/saved_objects/routes/export.ts +++ b/src/core/server/saved_objects/routes/export.ts @@ -18,7 +18,7 @@ import { SavedObjectsExportByObjectOptions, SavedObjectsExportError, } from '../export'; -import { validateTypes, validateObjects } from './utils'; +import { validateTypes, validateObjects, catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { config: SavedObjectConfig; @@ -163,7 +163,7 @@ export const registerExportRoute = ( }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const cleaned = cleanOptions(req.body); const supportedTypes = context.core.savedObjects.typeRegistry .getImportableAndExportableTypes() diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts index 747070e54e5ad..c814fd310dc52 100644 --- a/src/core/server/saved_objects/routes/find.ts +++ b/src/core/server/saved_objects/routes/find.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -49,7 +50,7 @@ export const registerFindRoute = (router: IRouter, { coreUsageData }: RouteDepen }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const query = req.query; const namespaces = diff --git a/src/core/server/saved_objects/routes/get.ts b/src/core/server/saved_objects/routes/get.ts index c66a11dcf0cdd..2dd812f35cefd 100644 --- a/src/core/server/saved_objects/routes/get.ts +++ b/src/core/server/saved_objects/routes/get.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -25,7 +26,7 @@ export const registerGetRoute = (router: IRouter, { coreUsageData }: RouteDepend }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { type, id } = req.params; const usageStatsClient = coreUsageData.getClient(); diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index 6c4c759460ce3..5fd132acafbed 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -13,7 +13,7 @@ import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; import { SavedObjectConfig } from '../saved_objects_config'; import { SavedObjectsImportError } from '../import'; -import { createSavedObjectsStreamFromNdJson } from './utils'; +import { catchAndReturnBoomErrors, createSavedObjectsStreamFromNdJson } from './utils'; interface RouteDependencies { config: SavedObjectConfig; @@ -61,7 +61,7 @@ export const registerImportRoute = ( }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { overwrite, createNewCopies } = req.query; const usageStatsClient = coreUsageData.getClient(); diff --git a/src/core/server/saved_objects/routes/migrate.ts b/src/core/server/saved_objects/routes/migrate.ts index 8b347d4725b08..7c2f4bfb06710 100644 --- a/src/core/server/saved_objects/routes/migrate.ts +++ b/src/core/server/saved_objects/routes/migrate.ts @@ -8,6 +8,7 @@ import { IRouter } from '../../http'; import { IKibanaMigrator } from '../migrations'; +import { catchAndReturnBoomErrors } from './utils'; export const registerMigrateRoute = ( router: IRouter, @@ -21,7 +22,7 @@ export const registerMigrateRoute = ( tags: ['access:migrateSavedObjects'], }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const migrator = await migratorPromise; await migrator.runMigrations({ rerun: true }); return res.ok({ diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts index 0cf976c30b311..6f0a3d028baf9 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -13,8 +13,7 @@ import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; import { SavedObjectConfig } from '../saved_objects_config'; import { SavedObjectsImportError } from '../import'; -import { createSavedObjectsStreamFromNdJson } from './utils'; - +import { catchAndReturnBoomErrors, createSavedObjectsStreamFromNdJson } from './utils'; interface RouteDependencies { config: SavedObjectConfig; coreUsageData: CoreUsageDataSetup; @@ -69,7 +68,7 @@ export const registerResolveImportErrorsRoute = ( }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { createNewCopies } = req.query; const usageStatsClient = coreUsageData.getClient(); diff --git a/src/core/server/saved_objects/routes/update.ts b/src/core/server/saved_objects/routes/update.ts index 17cfd438d47bf..dbc69f743df76 100644 --- a/src/core/server/saved_objects/routes/update.ts +++ b/src/core/server/saved_objects/routes/update.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -38,7 +39,7 @@ export const registerUpdateRoute = (router: IRouter, { coreUsageData }: RouteDep }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { type, id } = req.params; const { attributes, version, references } = req.body; const options = { version, references }; diff --git a/src/core/server/saved_objects/routes/utils.test.ts b/src/core/server/saved_objects/routes/utils.test.ts index ade7b03f6a8c2..1d7e86e288b18 100644 --- a/src/core/server/saved_objects/routes/utils.test.ts +++ b/src/core/server/saved_objects/routes/utils.test.ts @@ -9,6 +9,15 @@ import { createSavedObjectsStreamFromNdJson, validateTypes, validateObjects } from './utils'; import { Readable } from 'stream'; import { createPromiseFromStreams, createConcatStream } from '@kbn/utils'; +import { catchAndReturnBoomErrors } from './utils'; +import Boom from '@hapi/boom'; +import { + KibanaRequest, + RequestHandler, + RequestHandlerContext, + KibanaResponseFactory, + kibanaResponseFactory, +} from '../../'; async function readStreamToCompletion(stream: Readable) { return createPromiseFromStreams([stream, createConcatStream([])]); @@ -143,3 +152,69 @@ describe('validateObjects', () => { ).toBeUndefined(); }); }); + +describe('catchAndReturnBoomErrors', () => { + let context: RequestHandlerContext; + let request: KibanaRequest; + let response: KibanaResponseFactory; + + const createHandler = (handler: () => any): RequestHandler => () => { + return handler(); + }; + + beforeEach(() => { + context = {} as any; + request = {} as any; + response = kibanaResponseFactory; + }); + + it('should pass-though call parameters to the handler', async () => { + const handler = jest.fn(); + const wrapped = catchAndReturnBoomErrors(handler); + await wrapped(context, request, response); + expect(handler).toHaveBeenCalledWith(context, request, response); + }); + + it('should pass-though result from the handler', async () => { + const handler = createHandler(() => { + return 'handler-response'; + }); + const wrapped = catchAndReturnBoomErrors(handler); + const result = await wrapped(context, request, response); + expect(result).toBe('handler-response'); + }); + + it('should intercept and convert thrown Boom errors', async () => { + const handler = createHandler(() => { + throw Boom.notFound('not there'); + }); + const wrapped = catchAndReturnBoomErrors(handler); + const result = await wrapped(context, request, response); + expect(result.status).toBe(404); + expect(result.payload).toEqual({ + error: 'Not Found', + message: 'not there', + statusCode: 404, + }); + }); + + it('should re-throw non-Boom errors', async () => { + const handler = createHandler(() => { + throw new Error('something went bad'); + }); + const wrapped = catchAndReturnBoomErrors(handler); + await expect(wrapped(context, request, response)).rejects.toMatchInlineSnapshot( + `[Error: something went bad]` + ); + }); + + it('should re-throw Boom internal/500 errors', async () => { + const handler = createHandler(() => { + throw Boom.internal(); + }); + const wrapped = catchAndReturnBoomErrors(handler); + await expect(wrapped(context, request, response)).rejects.toMatchInlineSnapshot( + `[Error: Internal Server Error]` + ); + }); +}); diff --git a/src/core/server/saved_objects/routes/utils.ts b/src/core/server/saved_objects/routes/utils.ts index b9e7df48a4b4c..269f3f0698561 100644 --- a/src/core/server/saved_objects/routes/utils.ts +++ b/src/core/server/saved_objects/routes/utils.ts @@ -7,7 +7,11 @@ */ import { Readable } from 'stream'; -import { SavedObject, SavedObjectsExportResultDetails } from 'src/core/server'; +import { + RequestHandlerWrapper, + SavedObject, + SavedObjectsExportResultDetails, +} from 'src/core/server'; import { createSplitStream, createMapStream, @@ -16,6 +20,7 @@ import { createListStream, createConcatStream, } from '@kbn/utils'; +import Boom from '@hapi/boom'; export async function createSavedObjectsStreamFromNdJson(ndJsonStream: Readable) { const savedObjects = await createPromiseFromStreams([ @@ -52,3 +57,30 @@ export function validateObjects( .join(', ')}`; } } + +/** + * Catches errors thrown by saved object route handlers and returns an error + * with the payload and statusCode of the boom error. + * + * This is very close to the core `router.handleLegacyErrors` except that it + * throws internal errors (statusCode: 500) so that the internal error's + * message get logged by Core. + * + * TODO: Remove once https://github.com/elastic/kibana/issues/65291 is fixed. + */ +export const catchAndReturnBoomErrors: RequestHandlerWrapper = (handler) => { + return async (context, request, response) => { + try { + return await handler(context, request, response); + } catch (e) { + if (Boom.isBoom(e) && e.output.statusCode !== 500) { + return response.customError({ + body: e.output.payload, + statusCode: e.output.statusCode, + headers: e.output.headers as { [key: string]: string }, + }); + } + throw e; + } + }; +}; diff --git a/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts b/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts index cc497ca6348b8..da1ebec2c0f7d 100644 --- a/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts +++ b/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts @@ -109,6 +109,27 @@ describe('savedObjectsClient/decorateEsError', () => { expect(SavedObjectsErrorHelpers.isNotFoundError(genericError)).toBe(true); }); + it('if saved objects index does not exist makes NotFound a SavedObjectsClient/generalError', () => { + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 404, + body: { + error: { + reason: + 'no such index [.kibana_8.0.0] and [require_alias] request flag is [true] and [.kibana_8.0.0] is not an alias', + }, + }, + }) + ); + expect(SavedObjectsErrorHelpers.isGeneralError(error)).toBe(false); + const genericError = decorateEsError(error); + expect(genericError.message).toEqual( + `Saved object index alias [.kibana_8.0.0] not found: Response Error` + ); + expect(genericError.output.statusCode).toBe(500); + expect(SavedObjectsErrorHelpers.isGeneralError(error)).toBe(true); + }); + it('makes BadRequest a SavedObjectsClient/BadRequest error', () => { const error = new esErrors.ResponseError( elasticsearchClientMock.createApiResponse({ statusCode: 400 }) diff --git a/src/core/server/saved_objects/service/lib/decorate_es_error.ts b/src/core/server/saved_objects/service/lib/decorate_es_error.ts index 40f18c9c94c25..aabca2d602cb3 100644 --- a/src/core/server/saved_objects/service/lib/decorate_es_error.ts +++ b/src/core/server/saved_objects/service/lib/decorate_es_error.ts @@ -63,6 +63,12 @@ export function decorateEsError(error: EsErrors) { } if (responseErrors.isNotFound(error.statusCode)) { + const match = error?.meta?.body?.error?.reason?.match( + /no such index \[(.+)\] and \[require_alias\] request flag is \[true\] and \[.+\] is not an alias/ + ); + if (match?.length > 0) { + return SavedObjectsErrorHelpers.decorateIndexAliasNotFoundError(error, match[1]); + } return SavedObjectsErrorHelpers.createGenericNotFoundError(); } diff --git a/src/core/server/saved_objects/service/lib/errors.ts b/src/core/server/saved_objects/service/lib/errors.ts index f216e72efbcf8..c348196aaba21 100644 --- a/src/core/server/saved_objects/service/lib/errors.ts +++ b/src/core/server/saved_objects/service/lib/errors.ts @@ -135,6 +135,19 @@ export class SavedObjectsErrorHelpers { return decorate(Boom.notFound(), CODE_NOT_FOUND, 404); } + public static createIndexAliasNotFoundError(alias: string) { + return SavedObjectsErrorHelpers.decorateIndexAliasNotFoundError(Boom.internal(), alias); + } + + public static decorateIndexAliasNotFoundError(error: Error, alias: string) { + return decorate( + error, + CODE_GENERAL_ERROR, + 500, + `Saved object index alias [${alias}] not found` + ); + } + public static isNotFoundError(error: Error | DecoratedError) { return isSavedObjectsClientError(error) && error[code] === CODE_NOT_FOUND; } @@ -185,4 +198,8 @@ export class SavedObjectsErrorHelpers { public static decorateGeneralError(error: Error, reason?: string) { return decorate(error, CODE_GENERAL_ERROR, 500, reason); } + + public static isGeneralError(error: Error | DecoratedError) { + return isSavedObjectsClientError(error) && error[code] === CODE_GENERAL_ERROR; + } } diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 216e1c4bd2d3c..68fdea0f9eb25 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -18,6 +18,7 @@ import { DocumentMigrator } from '../../migrations/core/document_migrator'; import { mockKibanaMigrator } from '../../migrations/kibana/kibana_migrator.mock'; import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import { esKuery } from '../../es_query'; +import { errors as EsErrors } from '@elastic/elasticsearch'; const { nodeTypes } = esKuery; jest.mock('./search_dsl/search_dsl', () => ({ getSearchDsl: jest.fn() })); @@ -4341,8 +4342,14 @@ describe('SavedObjectsRepository', () => { }); it(`throws when ES is unable to find the document during update`, async () => { + const notFoundError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 404, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); client.update.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + elasticsearchClientMock.createErrorTransportRequestPromise(notFoundError) ); await expectNotFoundError(type, id); expect(client.update).toHaveBeenCalledTimes(1); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 2993d4234bd2e..da80971635a93 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -299,6 +299,7 @@ export class SavedObjectsRepository { refresh, body: raw._source, ...(overwrite && version ? decodeRequestVersion(version) : {}), + require_alias: true, }; const { body } = @@ -469,6 +470,7 @@ export class SavedObjectsRepository { const bulkResponse = bulkCreateParams.length ? await this.client.bulk({ refresh, + require_alias: true, body: bulkCreateParams, }) : undefined; @@ -1117,8 +1119,8 @@ export class SavedObjectsRepository { ...(Array.isArray(references) && { references }), }; - const { body, statusCode } = await this.client.update( - { + const { body } = await this.client + .update({ id: this._serializer.generateRawId(namespace, type, id), index: this.getIndexForType(type), ...getExpectedVersionProperties(version, preflightResult), @@ -1128,14 +1130,15 @@ export class SavedObjectsRepository { doc, }, _source_includes: ['namespace', 'namespaces', 'originId'], - }, - { ignore: [404] } - ); - - if (statusCode === 404) { - // see "404s from missing index" above - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } + require_alias: true, + }) + .catch((err) => { + if (SavedObjectsErrorHelpers.isNotFoundError(err)) { + // see "404s from missing index" above + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + throw err; + }); const { originId } = body.get._source; let namespaces = []; @@ -1496,6 +1499,7 @@ export class SavedObjectsRepository { refresh, body: bulkUpdateParams, _source_includes: ['originId'], + require_alias: true, }) : undefined; @@ -1712,6 +1716,7 @@ export class SavedObjectsRepository { id: raw._id, index: this.getIndexForType(type), refresh, + require_alias: true, _source: 'true', body: { script: { @@ -1933,12 +1938,18 @@ export class SavedObjectsRepository { } } -function getBulkOperationError(error: { type: string; reason?: string }, type: string, id: string) { +function getBulkOperationError( + error: { type: string; reason?: string; index?: string }, + type: string, + id: string +) { switch (error.type) { case 'version_conflict_engine_exception': return errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)); case 'document_missing_exception': return errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)); + case 'index_not_found_exception': + return errorContent(SavedObjectsErrorHelpers.createIndexAliasNotFoundError(error.index!)); default: return { message: error.reason || JSON.stringify(error), diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index aadd16bde0ee6..9d5114e645f6e 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2336,6 +2336,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static createGenericNotFoundError(type?: string | null, id?: string | null): DecoratedError; // (undocumented) + static createIndexAliasNotFoundError(alias: string): DecoratedError; + // (undocumented) static createInvalidVersionError(versionInput?: string): DecoratedError; // (undocumented) static createTooManyRequestsError(type: string, id: string): DecoratedError; @@ -2354,6 +2356,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static decorateGeneralError(error: Error, reason?: string): DecoratedError; // (undocumented) + static decorateIndexAliasNotFoundError(error: Error, alias: string): DecoratedError; + // (undocumented) static decorateNotAuthorizedError(error: Error, reason?: string): DecoratedError; // (undocumented) static decorateRequestEntityTooLargeError(error: Error, reason?: string): DecoratedError; @@ -2370,6 +2374,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static isForbiddenError(error: Error | DecoratedError): boolean; // (undocumented) + static isGeneralError(error: Error | DecoratedError): boolean; + // (undocumented) static isInvalidVersionError(error: Error | DecoratedError): boolean; // (undocumented) static isNotAuthorizedError(error: Error | DecoratedError): boolean; diff --git a/src/core/server/ui_settings/integration_tests/doc_exists.ts b/src/core/server/ui_settings/integration_tests/doc_exists.ts index aa6f98ddf2d03..d100b89af9609 100644 --- a/src/core/server/ui_settings/integration_tests/doc_exists.ts +++ b/src/core/server/ui_settings/integration_tests/doc_exists.ts @@ -8,7 +8,7 @@ import { getServices, chance } from './lib'; -export function docExistsSuite() { +export const docExistsSuite = (savedObjectsIndex: string) => () => { async function setup(options: any = {}) { const { initialSettings } = options; @@ -16,7 +16,7 @@ export function docExistsSuite() { // delete the kibana index to ensure we start fresh await callCluster('deleteByQuery', { - index: kbnServer.config.get('kibana.index'), + index: savedObjectsIndex, body: { conflicts: 'proceed', query: { match_all: {} }, @@ -212,4 +212,4 @@ export function docExistsSuite() { }); }); }); -} +}; diff --git a/src/core/server/ui_settings/integration_tests/doc_missing.ts b/src/core/server/ui_settings/integration_tests/doc_missing.ts index 501976e3823f1..822ffe398b87d 100644 --- a/src/core/server/ui_settings/integration_tests/doc_missing.ts +++ b/src/core/server/ui_settings/integration_tests/doc_missing.ts @@ -8,7 +8,7 @@ import { getServices, chance } from './lib'; -export function docMissingSuite() { +export const docMissingSuite = (savedObjectsIndex: string) => () => { // ensure the kibana index has no documents beforeEach(async () => { const { kbnServer, callCluster } = getServices(); @@ -22,7 +22,7 @@ export function docMissingSuite() { // delete all docs from kibana index to ensure savedConfig is not found await callCluster('deleteByQuery', { - index: kbnServer.config.get('kibana.index'), + index: savedObjectsIndex, body: { query: { match_all: {} }, }, @@ -136,4 +136,4 @@ export function docMissingSuite() { }); }); }); -} +}; diff --git a/src/core/server/ui_settings/integration_tests/doc_missing_and_index_read_only.ts b/src/core/server/ui_settings/integration_tests/doc_missing_and_index_read_only.ts index b2ff1b2f1d4ab..997d51e36abdc 100644 --- a/src/core/server/ui_settings/integration_tests/doc_missing_and_index_read_only.ts +++ b/src/core/server/ui_settings/integration_tests/doc_missing_and_index_read_only.ts @@ -8,7 +8,7 @@ import { getServices, chance } from './lib'; -export function docMissingAndIndexReadOnlySuite() { +export const docMissingAndIndexReadOnlySuite = (savedObjectsIndex: string) => () => { // ensure the kibana index has no documents beforeEach(async () => { const { kbnServer, callCluster } = getServices(); @@ -22,7 +22,7 @@ export function docMissingAndIndexReadOnlySuite() { // delete all docs from kibana index to ensure savedConfig is not found await callCluster('deleteByQuery', { - index: kbnServer.config.get('kibana.index'), + index: savedObjectsIndex, body: { query: { match_all: {} }, }, @@ -30,7 +30,7 @@ export function docMissingAndIndexReadOnlySuite() { // set the index to read only await callCluster('indices.putSettings', { - index: kbnServer.config.get('kibana.index'), + index: savedObjectsIndex, body: { index: { blocks: { @@ -42,11 +42,11 @@ export function docMissingAndIndexReadOnlySuite() { }); afterEach(async () => { - const { kbnServer, callCluster } = getServices(); + const { callCluster } = getServices(); // disable the read only block await callCluster('indices.putSettings', { - index: kbnServer.config.get('kibana.index'), + index: savedObjectsIndex, body: { index: { blocks: { @@ -142,4 +142,4 @@ export function docMissingAndIndexReadOnlySuite() { }); }); }); -} +}; diff --git a/src/core/server/ui_settings/integration_tests/index.test.ts b/src/core/server/ui_settings/integration_tests/index.test.ts index f415f1d73de7d..e27e6c4e46874 100644 --- a/src/core/server/ui_settings/integration_tests/index.test.ts +++ b/src/core/server/ui_settings/integration_tests/index.test.ts @@ -6,20 +6,25 @@ * Public License, v 1. */ +import { Env } from '@kbn/config'; +import { REPO_ROOT } from '@kbn/dev-utils'; +import { getEnvOptions } from '@kbn/config/target/mocks'; import { startServers, stopServers } from './lib'; - import { docExistsSuite } from './doc_exists'; import { docMissingSuite } from './doc_missing'; import { docMissingAndIndexReadOnlySuite } from './doc_missing_and_index_read_only'; +const kibanaVersion = Env.createDefault(REPO_ROOT, getEnvOptions()).packageInfo.version; +const savedObjectIndex = `.kibana_${kibanaVersion}_001`; + describe('uiSettings/routes', function () { jest.setTimeout(10000); beforeAll(startServers); /* eslint-disable jest/valid-describe */ - describe('doc missing', docMissingSuite); - describe('doc missing and index readonly', docMissingAndIndexReadOnlySuite); - describe('doc exists', docExistsSuite); + describe('doc missing', docMissingSuite(savedObjectIndex)); + describe('doc missing and index readonly', docMissingAndIndexReadOnlySuite(savedObjectIndex)); + describe('doc exists', docExistsSuite(savedObjectIndex)); /* eslint-enable jest/valid-describe */ afterAll(stopServers); }); diff --git a/src/core/server/ui_settings/integration_tests/lib/servers.ts b/src/core/server/ui_settings/integration_tests/lib/servers.ts index b5198b19007d0..f181272030ae1 100644 --- a/src/core/server/ui_settings/integration_tests/lib/servers.ts +++ b/src/core/server/ui_settings/integration_tests/lib/servers.ts @@ -37,9 +37,6 @@ export async function startServers() { adjustTimeout: (t) => jest.setTimeout(t), settings: { kbn: { - migrations: { - enableV2: false, - }, uiSettings: { overrides: { foo: 'bar', diff --git a/src/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index 0007e1fcca0a5..6fe6819a0981a 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -40,7 +40,7 @@ const DEFAULTS_SETTINGS = { }, logging: { silent: true }, plugins: {}, - migrations: { skip: true }, + migrations: { skip: false }, }; const DEFAULT_SETTINGS_WITH_CORE_PLUGINS = { diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index 7ea181715717b..6955365ebca3f 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -265,6 +265,13 @@ export function DashboardApp({ }; }, [dashboardStateManager, dashboardContainer, onAppLeave, embeddable]); + // clear search session when leaving dashboard route + useEffect(() => { + return () => { + data.search.session.clear(); + }; + }, [data.search.session]); + return (
{savedDashboard && dashboardStateManager && dashboardContainer && viewMode && ( diff --git a/src/plugins/dashboard/server/index.ts b/src/plugins/dashboard/server/index.ts index cc784f5f81c9e..4bd43d1cd64a9 100644 --- a/src/plugins/dashboard/server/index.ts +++ b/src/plugins/dashboard/server/index.ts @@ -25,3 +25,4 @@ export function plugin(initializerContext: PluginInitializerContext) { } export { DashboardPluginSetup, DashboardPluginStart } from './types'; +export { findByValueEmbeddables } from './usage/find_by_value_embeddables'; diff --git a/src/plugins/dashboard/server/usage/find_by_value_embeddables.test.ts b/src/plugins/dashboard/server/usage/find_by_value_embeddables.test.ts new file mode 100644 index 0000000000000..3da6a8050f14c --- /dev/null +++ b/src/plugins/dashboard/server/usage/find_by_value_embeddables.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { SavedDashboardPanel730ToLatest } from '../../common'; +import { findByValueEmbeddables } from './find_by_value_embeddables'; + +const visualizationByValue = ({ + embeddableConfig: { + value: 'visualization-by-value', + }, + type: 'visualization', +} as unknown) as SavedDashboardPanel730ToLatest; + +const mapByValue = ({ + embeddableConfig: { + value: 'map-by-value', + }, + type: 'map', +} as unknown) as SavedDashboardPanel730ToLatest; + +const embeddableByRef = ({ + panelRefName: 'panel_ref_1', +} as unknown) as SavedDashboardPanel730ToLatest; + +describe('findByValueEmbeddables', () => { + it('finds the by value embeddables for the given type', async () => { + const savedObjectsResult = { + saved_objects: [ + { + attributes: { + panelsJSON: JSON.stringify([visualizationByValue, mapByValue, embeddableByRef]), + }, + }, + { + attributes: { + panelsJSON: JSON.stringify([embeddableByRef, mapByValue, visualizationByValue]), + }, + }, + ], + }; + const savedObjectClient = { find: jest.fn().mockResolvedValue(savedObjectsResult) }; + + const maps = await findByValueEmbeddables(savedObjectClient, 'map'); + + expect(maps.length).toBe(2); + expect(maps[0]).toEqual(mapByValue.embeddableConfig); + expect(maps[1]).toEqual(mapByValue.embeddableConfig); + + const visualizations = await findByValueEmbeddables(savedObjectClient, 'visualization'); + + expect(visualizations.length).toBe(2); + expect(visualizations[0]).toEqual(visualizationByValue.embeddableConfig); + expect(visualizations[1]).toEqual(visualizationByValue.embeddableConfig); + }); +}); diff --git a/src/plugins/dashboard/server/usage/find_by_value_embeddables.ts b/src/plugins/dashboard/server/usage/find_by_value_embeddables.ts new file mode 100644 index 0000000000000..0ae14cdcf7197 --- /dev/null +++ b/src/plugins/dashboard/server/usage/find_by_value_embeddables.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { ISavedObjectsRepository, SavedObjectAttributes } from 'kibana/server'; +import { SavedDashboardPanel730ToLatest } from '../../common'; + +export const findByValueEmbeddables = async ( + savedObjectClient: Pick, + embeddableType: string +) => { + const dashboards = await savedObjectClient.find({ + type: 'dashboard', + }); + + return dashboards.saved_objects + .map((dashboard) => { + try { + return (JSON.parse( + dashboard.attributes.panelsJSON as string + ) as unknown) as SavedDashboardPanel730ToLatest[]; + } catch (exception) { + return []; + } + }) + .flat() + .filter((panel) => (panel as Record).panelRefName === undefined) + .filter((panel) => panel.type === embeddableType) + .map((panel) => panel.embeddableConfig); +}; diff --git a/src/plugins/data/common/search/search_source/mocks.ts b/src/plugins/data/common/search/search_source/mocks.ts index 328f05fac8594..08fe2b07096bb 100644 --- a/src/plugins/data/common/search/search_source/mocks.ts +++ b/src/plugins/data/common/search/search_source/mocks.ts @@ -6,7 +6,7 @@ * Public License, v 1. */ -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import type { MockedKeys } from '@kbn/utility-types/jest'; import { uiSettingsServiceMock } from '../../../../../core/public/mocks'; @@ -27,6 +27,7 @@ export const searchSourceInstanceMock: MockedKeys = { createChild: jest.fn().mockReturnThis(), setParent: jest.fn(), getParent: jest.fn().mockReturnThis(), + fetch$: jest.fn().mockReturnValue(of({})), fetch: jest.fn().mockResolvedValue({}), onRequestStart: jest.fn(), getSearchRequestBody: jest.fn(), diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index 6d7654c6659f2..c2a4beb9b61a5 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -51,7 +51,14 @@ describe('SearchSource', () => { let searchSource: SearchSource; beforeEach(() => { - mockSearchMethod = jest.fn().mockReturnValue(of({ rawResponse: '' })); + mockSearchMethod = jest + .fn() + .mockReturnValue( + of( + { rawResponse: { isPartial: true, isRunning: true } }, + { rawResponse: { isPartial: false, isRunning: false } } + ) + ); searchSourceDependencies = { getConfig: jest.fn(), @@ -564,6 +571,34 @@ describe('SearchSource', () => { await searchSource.fetch(options); expect(mockSearchMethod).toBeCalledTimes(1); }); + + test('should return partial results', (done) => { + searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies); + const options = {}; + + const next = jest.fn(); + const complete = () => { + expect(next).toBeCalledTimes(2); + expect(next.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "isPartial": true, + "isRunning": true, + }, + ] + `); + expect(next.mock.calls[1]).toMatchInlineSnapshot(` + Array [ + Object { + "isPartial": false, + "isRunning": false, + }, + ] + `); + done(); + }; + searchSource.fetch$(options).subscribe({ next, complete }); + }); }); describe('#serialize', () => { diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index 554e8385881f2..bb60f0d7b4ad4 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -60,7 +60,8 @@ import { setWith } from '@elastic/safer-lodash-set'; import { uniqueId, keyBy, pick, difference, omit, isObject, isFunction } from 'lodash'; -import { map } from 'rxjs/operators'; +import { map, switchMap, tap } from 'rxjs/operators'; +import { defer, from } from 'rxjs'; import { normalizeSortRequest } from './normalize_sort_request'; import { fieldWildcardFilter } from '../../../../kibana_utils/common'; import { IIndexPattern } from '../../index_patterns'; @@ -244,30 +245,35 @@ export class SearchSource { } /** - * Fetch this source and reject the returned Promise on error - * - * @async + * Fetch this source from Elasticsearch, returning an observable over the response(s) + * @param options */ - async fetch(options: ISearchOptions = {}) { + fetch$(options: ISearchOptions = {}) { const { getConfig } = this.dependencies; - await this.requestIsStarting(options); - - const searchRequest = await this.flatten(); - this.history = [searchRequest]; - - let response; - if (getConfig(UI_SETTINGS.COURIER_BATCH_SEARCHES)) { - response = await this.legacyFetch(searchRequest, options); - } else { - response = await this.fetchSearch(searchRequest, options); - } - - // TODO: Remove casting when https://github.com/elastic/elasticsearch-js/issues/1287 is resolved - if ((response as any).error) { - throw new RequestFailure(null, response); - } + return defer(() => this.requestIsStarting(options)).pipe( + switchMap(() => { + const searchRequest = this.flatten(); + this.history = [searchRequest]; + + return getConfig(UI_SETTINGS.COURIER_BATCH_SEARCHES) + ? from(this.legacyFetch(searchRequest, options)) + : this.fetchSearch$(searchRequest, options); + }), + tap((response) => { + // TODO: Remove casting when https://github.com/elastic/elasticsearch-js/issues/1287 is resolved + if ((response as any).error) { + throw new RequestFailure(null, response); + } + }) + ); + } - return response; + /** + * Fetch this source and reject the returned Promise on error + * @deprecated Use fetch$ instead + */ + fetch(options: ISearchOptions = {}) { + return this.fetch$(options).toPromise(); } /** @@ -305,16 +311,16 @@ export class SearchSource { * Run a search using the search service * @return {Promise>} */ - private fetchSearch(searchRequest: SearchRequest, options: ISearchOptions) { + private fetchSearch$(searchRequest: SearchRequest, options: ISearchOptions) { const { search, getConfig, onResponse } = this.dependencies; const params = getSearchParamsFromRequest(searchRequest, { getConfig, }); - return search({ params, indexType: searchRequest.indexType }, options) - .pipe(map(({ rawResponse }) => onResponse(searchRequest, rawResponse))) - .toPromise(); + return search({ params, indexType: searchRequest.indexType }, options).pipe( + map(({ rawResponse }) => onResponse(searchRequest, rawResponse)) + ); } /** diff --git a/src/plugins/data/common/search/test_data/illegal_argument_exception.json b/src/plugins/data/common/search/test_data/illegal_argument_exception.json new file mode 100644 index 0000000000000..ae48468abc209 --- /dev/null +++ b/src/plugins/data/common/search/test_data/illegal_argument_exception.json @@ -0,0 +1,14 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "illegal_argument_exception", + "reason" : "failed to parse setting [timeout] with value [1] as a time value: unit is missing or unrecognized" + } + ], + "type" : "illegal_argument_exception", + "reason" : "failed to parse setting [timeout] with value [1] as a time value: unit is missing or unrecognized" + }, + "status" : 400 + } + \ No newline at end of file diff --git a/src/plugins/data/common/search/test_data/index_not_found_exception.json b/src/plugins/data/common/search/test_data/index_not_found_exception.json new file mode 100644 index 0000000000000..dc892d95ae397 --- /dev/null +++ b/src/plugins/data/common/search/test_data/index_not_found_exception.json @@ -0,0 +1,21 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "index_not_found_exception", + "reason" : "no such index [poop]", + "resource.type" : "index_or_alias", + "resource.id" : "poop", + "index_uuid" : "_na_", + "index" : "poop" + } + ], + "type" : "index_not_found_exception", + "reason" : "no such index [poop]", + "resource.type" : "index_or_alias", + "resource.id" : "poop", + "index_uuid" : "_na_", + "index" : "poop" + }, + "status" : 404 +} diff --git a/src/plugins/data/common/search/test_data/json_e_o_f_exception.json b/src/plugins/data/common/search/test_data/json_e_o_f_exception.json new file mode 100644 index 0000000000000..88134e1c6ea03 --- /dev/null +++ b/src/plugins/data/common/search/test_data/json_e_o_f_exception.json @@ -0,0 +1,14 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "json_e_o_f_exception", + "reason" : "Unexpected end-of-input: expected close marker for Object (start marker at [Source: (org.elasticsearch.common.io.stream.InputStreamStreamInput); line: 1, column: 1])\n at [Source: (org.elasticsearch.common.io.stream.InputStreamStreamInput); line: 1, column: 2]" + } + ], + "type" : "json_e_o_f_exception", + "reason" : "Unexpected end-of-input: expected close marker for Object (start marker at [Source: (org.elasticsearch.common.io.stream.InputStreamStreamInput); line: 1, column: 1])\n at [Source: (org.elasticsearch.common.io.stream.InputStreamStreamInput); line: 1, column: 2]" + }, + "status" : 400 + } + \ No newline at end of file diff --git a/src/plugins/data/common/search/test_data/parsing_exception.json b/src/plugins/data/common/search/test_data/parsing_exception.json new file mode 100644 index 0000000000000..725a847aa0e3f --- /dev/null +++ b/src/plugins/data/common/search/test_data/parsing_exception.json @@ -0,0 +1,17 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "parsing_exception", + "reason" : "[terms] query does not support [ohno]", + "line" : 4, + "col" : 17 + } + ], + "type" : "parsing_exception", + "reason" : "[terms] query does not support [ohno]", + "line" : 4, + "col" : 17 + }, + "status" : 400 +} diff --git a/src/plugins/data/common/search/test_data/resource_not_found_exception.json b/src/plugins/data/common/search/test_data/resource_not_found_exception.json new file mode 100644 index 0000000000000..7f2a3b2e6e143 --- /dev/null +++ b/src/plugins/data/common/search/test_data/resource_not_found_exception.json @@ -0,0 +1,13 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "resource_not_found_exception", + "reason" : "FlZlSXp6dkd3UXdHZjhsalVtVHBnYkEdYjNIWDhDOTZRN3ExemdmVkx4RXNQQToxMjc2ODk=" + } + ], + "type" : "resource_not_found_exception", + "reason" : "FlZlSXp6dkd3UXdHZjhsalVtVHBnYkEdYjNIWDhDOTZRN3ExemdmVkx4RXNQQToxMjc2ODk=" + }, + "status" : 404 +} diff --git a/src/plugins/data/common/search/test_data/search_phase_execution_exception.json b/src/plugins/data/common/search/test_data/search_phase_execution_exception.json new file mode 100644 index 0000000000000..ff6879f2b8960 --- /dev/null +++ b/src/plugins/data/common/search/test_data/search_phase_execution_exception.json @@ -0,0 +1,52 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "script_exception", + "reason" : "compile error", + "script_stack" : [ + "invalid", + "^---- HERE" + ], + "script" : "invalid", + "lang" : "painless", + "position" : { + "offset" : 0, + "start" : 0, + "end" : 7 + } + } + ], + "type" : "search_phase_execution_exception", + "reason" : "all shards failed", + "phase" : "query", + "grouped" : true, + "failed_shards" : [ + { + "shard" : 0, + "index" : ".kibana_11", + "node" : "b3HX8C96Q7q1zgfVLxEsPA", + "reason" : { + "type" : "script_exception", + "reason" : "compile error", + "script_stack" : [ + "invalid", + "^---- HERE" + ], + "script" : "invalid", + "lang" : "painless", + "position" : { + "offset" : 0, + "start" : 0, + "end" : 7 + }, + "caused_by" : { + "type" : "illegal_argument_exception", + "reason" : "cannot resolve symbol [invalid]" + } + } + } + ] + }, + "status" : 400 +} diff --git a/src/plugins/data/common/search/test_data/x_content_parse_exception.json b/src/plugins/data/common/search/test_data/x_content_parse_exception.json new file mode 100644 index 0000000000000..cd6e1cb2c5977 --- /dev/null +++ b/src/plugins/data/common/search/test_data/x_content_parse_exception.json @@ -0,0 +1,17 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "x_content_parse_exception", + "reason" : "[5:13] [script] failed to parse object" + } + ], + "type" : "x_content_parse_exception", + "reason" : "[5:13] [script] failed to parse object", + "caused_by" : { + "type" : "json_parse_exception", + "reason" : "Unexpected character (''' (code 39)): expected a valid value (JSON String, Number, Array, Object or token 'null', 'true' or 'false')\n at [Source: (org.elasticsearch.common.bytes.AbstractBytesReference$BytesReferenceStreamInput); line: 5, column: 24]" + } + }, + "status" : 400 +} diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 9e493f46b0781..f533af2db9672 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -2282,8 +2282,11 @@ export class SearchInterceptor { protected readonly deps: SearchInterceptorDeps; // (undocumented) protected getTimeoutMode(): TimeoutErrorMode; + // Warning: (ae-forgotten-export) The symbol "KibanaServerError" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "AbortError" needs to be exported by the entry point index.d.ts + // // (undocumented) - protected handleSearchError(e: any, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; + protected handleSearchError(e: KibanaServerError | AbortError, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; // @internal protected pendingCount$: BehaviorSubject; // @internal (undocumented) @@ -2360,6 +2363,8 @@ export class SearchSource { createChild(options?: {}): SearchSource; createCopy(): SearchSource; destroy(): void; + fetch$(options?: ISearchOptions): import("rxjs").Observable>; + // @deprecated fetch(options?: ISearchOptions): Promise>; getField(field: K, recurse?: boolean): SearchSourceFields[K]; getFields(): { @@ -2451,7 +2456,7 @@ export interface SearchSourceFields { // // @public export class SearchTimeoutError extends KbnError { - constructor(err: Error, mode: TimeoutErrorMode); + constructor(err: Record, mode: TimeoutErrorMode); // (undocumented) getErrorMessage(application: ApplicationStart): JSX.Element; // (undocumented) @@ -2601,7 +2606,7 @@ export const UI_SETTINGS: { // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:138:7 - (ae-forgotten-export) The symbol "FieldAttrSet" needs to be exported by the entry point index.d.ts // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:169:7 - (ae-forgotten-export) The symbol "RuntimeField" needs to be exported by the entry point index.d.ts // src/plugins/data/common/search/aggs/types.ts:139:51 - (ae-forgotten-export) The symbol "AggTypesRegistryStart" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/search/search_source/search_source.ts:186:7 - (ae-forgotten-export) The symbol "SearchFieldValue" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/search/search_source/search_source.ts:187:7 - (ae-forgotten-export) The symbol "SearchFieldValue" needs to be exported by the entry point index.d.ts // src/plugins/data/public/field_formats/field_formats_service.ts:56:3 - (ae-forgotten-export) The symbol "FormatFactory" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:55:23 - (ae-forgotten-export) The symbol "FILTERS" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:55:23 - (ae-forgotten-export) The symbol "getDisplayValueFromFilter" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/errors/es_error.test.tsx b/src/plugins/data/public/search/errors/es_error.test.tsx index adb422c1d18e7..6a4cb9c494b4f 100644 --- a/src/plugins/data/public/search/errors/es_error.test.tsx +++ b/src/plugins/data/public/search/errors/es_error.test.tsx @@ -7,23 +7,22 @@ */ import { EsError } from './es_error'; -import { IEsError } from './types'; describe('EsError', () => { it('contains the same body as the wrapped error', () => { const error = { - body: { - attributes: { - error: { - type: 'top_level_exception_type', - reason: 'top-level reason', - }, + statusCode: 500, + message: 'nope', + attributes: { + error: { + type: 'top_level_exception_type', + reason: 'top-level reason', }, }, - } as IEsError; + } as any; const esError = new EsError(error); - expect(typeof esError.body).toEqual('object'); - expect(esError.body).toEqual(error.body); + expect(typeof esError.attributes).toEqual('object'); + expect(esError.attributes).toEqual(error.attributes); }); }); diff --git a/src/plugins/data/public/search/errors/es_error.tsx b/src/plugins/data/public/search/errors/es_error.tsx index fff06d2e1bfb6..d241eecfd8d5d 100644 --- a/src/plugins/data/public/search/errors/es_error.tsx +++ b/src/plugins/data/public/search/errors/es_error.tsx @@ -11,19 +11,19 @@ import { EuiCodeBlock, EuiSpacer } from '@elastic/eui'; import { ApplicationStart } from 'kibana/public'; import { KbnError } from '../../../../kibana_utils/common'; import { IEsError } from './types'; -import { getRootCause, getTopLevelCause } from './utils'; +import { getRootCause } from './utils'; export class EsError extends KbnError { - readonly body: IEsError['body']; + readonly attributes: IEsError['attributes']; constructor(protected readonly err: IEsError) { super('EsError'); - this.body = err.body; + this.attributes = err.attributes; } public getErrorMessage(application: ApplicationStart) { const rootCause = getRootCause(this.err)?.reason; - const topLevelCause = getTopLevelCause(this.err)?.reason; + const topLevelCause = this.attributes?.reason; const cause = rootCause ?? topLevelCause; return ( diff --git a/src/plugins/data/public/search/errors/painless_error.test.tsx b/src/plugins/data/public/search/errors/painless_error.test.tsx new file mode 100644 index 0000000000000..929f25e234a60 --- /dev/null +++ b/src/plugins/data/public/search/errors/painless_error.test.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 + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { coreMock } from '../../../../../core/public/mocks'; +const startMock = coreMock.createStart(); + +import { mount } from 'enzyme'; +import { PainlessError } from './painless_error'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import * as searchPhaseException from '../../../common/search/test_data/search_phase_execution_exception.json'; + +describe('PainlessError', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('Should show reason and code', () => { + const e = new PainlessError({ + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: searchPhaseException.error, + }); + const component = mount(e.getErrorMessage(startMock.application)); + + const scriptElem = findTestSubject(component, 'painlessScript').getDOMNode(); + + const failedShards = e.attributes?.failed_shards![0]; + const script = failedShards!.reason.script; + expect(scriptElem.textContent).toBe(`Error executing Painless script: '${script}'`); + + const stackTraceElem = findTestSubject(component, 'painlessStackTrace').getDOMNode(); + const stackTrace = failedShards!.reason.script_stack!.join('\n'); + expect(stackTraceElem.textContent).toBe(stackTrace); + + expect(component.find('EuiButton').length).toBe(1); + }); +}); diff --git a/src/plugins/data/public/search/errors/painless_error.tsx b/src/plugins/data/public/search/errors/painless_error.tsx index 8a4248e48185b..6d11f3a16b09e 100644 --- a/src/plugins/data/public/search/errors/painless_error.tsx +++ b/src/plugins/data/public/search/errors/painless_error.tsx @@ -33,10 +33,12 @@ export class PainlessError extends EsError { return ( <> - {i18n.translate('data.painlessError.painlessScriptedFieldErrorMessage', { - defaultMessage: "Error executing Painless script: '{script}'.", - values: { script: rootCause?.script }, - })} + + {i18n.translate('data.painlessError.painlessScriptedFieldErrorMessage', { + defaultMessage: "Error executing Painless script: '{script}'", + values: { script: rootCause?.script }, + })} + {painlessStack ? ( diff --git a/src/plugins/data/public/search/errors/timeout_error.tsx b/src/plugins/data/public/search/errors/timeout_error.tsx index ee2703b888bf1..6b9ce1b422481 100644 --- a/src/plugins/data/public/search/errors/timeout_error.tsx +++ b/src/plugins/data/public/search/errors/timeout_error.tsx @@ -24,7 +24,7 @@ export enum TimeoutErrorMode { */ export class SearchTimeoutError extends KbnError { public mode: TimeoutErrorMode; - constructor(err: Error, mode: TimeoutErrorMode) { + constructor(err: Record, mode: TimeoutErrorMode) { super(`Request timeout: ${JSON.stringify(err?.message)}`); this.mode = mode; } diff --git a/src/plugins/data/public/search/errors/types.ts b/src/plugins/data/public/search/errors/types.ts index d62cb311bf6a4..5806ef8676b9b 100644 --- a/src/plugins/data/public/search/errors/types.ts +++ b/src/plugins/data/public/search/errors/types.ts @@ -6,57 +6,47 @@ * Public License, v 1. */ +import { KibanaServerError } from '../../../../kibana_utils/common'; + export interface FailedShard { shard: number; index: string; node: string; - reason: { + reason: Reason; +} + +export interface Reason { + type: string; + reason: string; + script_stack?: string[]; + position?: { + offset: number; + start: number; + end: number; + }; + lang?: string; + script?: string; + caused_by?: { type: string; reason: string; - script_stack: string[]; - script: string; - lang: string; - position: { - offset: number; - start: number; - end: number; - }; - caused_by: { - type: string; - reason: string; - }; }; } -export interface IEsError { - body: { - statusCode: number; - error: string; - message: string; - attributes?: { - error?: { - root_cause?: [ - { - lang: string; - script: string; - } - ]; - type: string; - reason: string; - failed_shards: FailedShard[]; - caused_by: { - type: string; - reason: string; - phase: string; - grouped: boolean; - failed_shards: FailedShard[]; - script_stack: string[]; - }; - }; - }; - }; +export interface IEsErrorAttributes { + type: string; + reason: string; + root_cause?: Reason[]; + failed_shards?: FailedShard[]; } +export type IEsError = KibanaServerError; + +/** + * Checks if a given errors originated from Elasticsearch. + * Those params are assigned to the attributes property of an error. + * + * @param e + */ export function isEsError(e: any): e is IEsError { - return !!e.body?.attributes; + return !!e.attributes; } diff --git a/src/plugins/data/public/search/errors/utils.ts b/src/plugins/data/public/search/errors/utils.ts index d140e713f9440..7d303543a0c57 100644 --- a/src/plugins/data/public/search/errors/utils.ts +++ b/src/plugins/data/public/search/errors/utils.ts @@ -6,19 +6,15 @@ * Public License, v 1. */ -import { IEsError } from './types'; +import { FailedShard } from './types'; +import { KibanaServerError } from '../../../../kibana_utils/common'; -export function getFailedShards(err: IEsError) { - const failedShards = - err.body?.attributes?.error?.failed_shards || - err.body?.attributes?.error?.caused_by?.failed_shards; +export function getFailedShards(err: KibanaServerError): FailedShard | undefined { + const errorInfo = err.attributes; + const failedShards = errorInfo?.failed_shards || errorInfo?.caused_by?.failed_shards; return failedShards ? failedShards[0] : undefined; } -export function getTopLevelCause(err: IEsError) { - return err.body?.attributes?.error; -} - -export function getRootCause(err: IEsError) { +export function getRootCause(err: KibanaServerError) { return getFailedShards(err)?.reason; } diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts index 5ae01eccdd920..bfd73951c31c4 100644 --- a/src/plugins/data/public/search/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor.test.ts @@ -12,12 +12,15 @@ import { coreMock } from '../../../../core/public/mocks'; import { IEsSearchRequest } from '../../common/search'; import { SearchInterceptor } from './search_interceptor'; import { AbortError } from '../../../kibana_utils/public'; -import { SearchTimeoutError, PainlessError, TimeoutErrorMode } from './errors'; +import { SearchTimeoutError, PainlessError, TimeoutErrorMode, EsError } from './errors'; import { searchServiceMock } from './mocks'; import { ISearchStart, ISessionService } from '.'; import { bfetchPluginMock } from '../../../bfetch/public/mocks'; import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; +import * as searchPhaseException from '../../common/search/test_data/search_phase_execution_exception.json'; +import * as resourceNotFoundException from '../../common/search/test_data/resource_not_found_exception.json'; + let searchInterceptor: SearchInterceptor; let mockCoreSetup: MockedKeys; let bfetchSetup: jest.Mocked; @@ -64,15 +67,9 @@ describe('SearchInterceptor', () => { test('Renders a PainlessError', async () => { searchInterceptor.showError( new PainlessError({ - body: { - attributes: { - error: { - failed_shards: { - reason: 'bananas', - }, - }, - }, - } as any, + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: searchPhaseException.error, }) ); expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); @@ -161,10 +158,8 @@ describe('SearchInterceptor', () => { describe('Should handle Timeout errors', () => { test('Should throw SearchTimeoutError on server timeout AND show toast', async () => { const mockResponse: any = { - result: 500, - body: { - message: 'Request timed out', - }, + statusCode: 500, + message: 'Request timed out', }; fetchMock.mockRejectedValueOnce(mockResponse); const mockRequest: IEsSearchRequest = { @@ -177,10 +172,8 @@ describe('SearchInterceptor', () => { test('Timeout error should show multiple times if not in a session', async () => { const mockResponse: any = { - result: 500, - body: { - message: 'Request timed out', - }, + statusCode: 500, + message: 'Request timed out', }; fetchMock.mockRejectedValue(mockResponse); const mockRequest: IEsSearchRequest = { @@ -198,10 +191,8 @@ describe('SearchInterceptor', () => { test('Timeout error should show once per each session', async () => { const mockResponse: any = { - result: 500, - body: { - message: 'Request timed out', - }, + statusCode: 500, + message: 'Request timed out', }; fetchMock.mockRejectedValue(mockResponse); const mockRequest: IEsSearchRequest = { @@ -219,10 +210,8 @@ describe('SearchInterceptor', () => { test('Timeout error should show once in a single session', async () => { const mockResponse: any = { - result: 500, - body: { - message: 'Request timed out', - }, + statusCode: 500, + message: 'Request timed out', }; fetchMock.mockRejectedValue(mockResponse); const mockRequest: IEsSearchRequest = { @@ -240,22 +229,9 @@ describe('SearchInterceptor', () => { test('Should throw Painless error on server error with OSS format', async () => { const mockResponse: any = { - result: 500, - body: { - attributes: { - error: { - failed_shards: [ - { - reason: { - lang: 'painless', - script_stack: ['a', 'b'], - reason: 'banana', - }, - }, - ], - }, - }, - }, + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: searchPhaseException.error, }; fetchMock.mockRejectedValueOnce(mockResponse); const mockRequest: IEsSearchRequest = { @@ -265,6 +241,20 @@ describe('SearchInterceptor', () => { await expect(response.toPromise()).rejects.toThrow(PainlessError); }); + test('Should throw ES error on ES server error', async () => { + const mockResponse: any = { + statusCode: 400, + message: 'resource_not_found_exception', + attributes: resourceNotFoundException.error, + }; + fetchMock.mockRejectedValueOnce(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + const response = searchInterceptor.search(mockRequest); + await expect(response.toPromise()).rejects.toThrow(EsError); + }); + test('Observable should fail if user aborts (test merged signal)', async () => { const abortController = new AbortController(); fetchMock.mockImplementationOnce((options: any) => { diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index f6ca9ef1a993d..6dfc8faea769e 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -6,7 +6,7 @@ * Public License, v 1. */ -import { get, memoize } from 'lodash'; +import { memoize } from 'lodash'; import { BehaviorSubject, throwError, timer, defer, from, Observable, NEVER } from 'rxjs'; import { catchError, finalize } from 'rxjs/operators'; import { PublicMethodsOf } from '@kbn/utility-types'; @@ -25,7 +25,11 @@ import { getHttpError, } from './errors'; import { toMountPoint } from '../../../kibana_react/public'; -import { AbortError, getCombinedAbortSignal } from '../../../kibana_utils/public'; +import { + AbortError, + getCombinedAbortSignal, + KibanaServerError, +} from '../../../kibana_utils/public'; import { ISessionService } from './session'; export interface SearchInterceptorDeps { @@ -87,8 +91,12 @@ export class SearchInterceptor { * @returns `Error` a search service specific error or the original error, if a specific error can't be recognized. * @internal */ - protected handleSearchError(e: any, timeoutSignal: AbortSignal, options?: ISearchOptions): Error { - if (timeoutSignal.aborted || get(e, 'body.message') === 'Request timed out') { + protected handleSearchError( + e: KibanaServerError | AbortError, + timeoutSignal: AbortSignal, + options?: ISearchOptions + ): Error { + if (timeoutSignal.aborted || e.message === 'Request timed out') { // Handle a client or a server side timeout const err = new SearchTimeoutError(e, this.getTimeoutMode()); @@ -96,7 +104,7 @@ export class SearchInterceptor { // The timeout error is shown any time a request times out, or once per session, if the request is part of a session. this.showTimeoutError(err, options?.sessionId); return err; - } else if (options?.abortSignal?.aborted) { + } else if (e instanceof AbortError) { // In the case an application initiated abort, throw the existing AbortError. return e; } else if (isEsError(e)) { @@ -106,12 +114,13 @@ export class SearchInterceptor { return new EsError(e); } } else { - return e; + return e instanceof Error ? e : new Error(e.message); } } /** * @internal + * @throws `AbortError` | `ErrorLike` */ protected runSearch( request: IKibanaSearchRequest, @@ -234,7 +243,7 @@ export class SearchInterceptor { }); this.pendingCount$.next(this.pendingCount$.getValue() + 1); return from(this.runSearch(request, { ...options, abortSignal: combinedSignal })).pipe( - catchError((e: Error) => { + catchError((e: Error | AbortError) => { return throwError(this.handleSearchError(e, timeoutSignal, options)); }), finalize(() => { diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.test.ts b/src/plugins/data/server/search/es_search/es_search_strategy.test.ts index 8e66729825e39..eeef46381732e 100644 --- a/src/plugins/data/server/search/es_search/es_search_strategy.test.ts +++ b/src/plugins/data/server/search/es_search/es_search_strategy.test.ts @@ -6,37 +6,56 @@ * Public License, v 1. */ +import { + elasticsearchClientMock, + MockedTransportRequestPromise, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../core/server/elasticsearch/client/mocks'; import { pluginInitializerContextConfigMock } from '../../../../../core/server/mocks'; import { esSearchStrategyProvider } from './es_search_strategy'; import { SearchStrategyDependencies } from '../types'; +import * as indexNotFoundException from '../../../common/search/test_data/index_not_found_exception.json'; +import { ElasticsearchClientError, ResponseError } from '@elastic/elasticsearch/lib/errors'; +import { KbnServerError } from '../../../../kibana_utils/server'; + describe('ES search strategy', () => { + const successBody = { + _shards: { + total: 10, + failed: 1, + skipped: 2, + successful: 7, + }, + }; + let mockedApiCaller: MockedTransportRequestPromise; + let mockApiCaller: jest.Mock<() => MockedTransportRequestPromise>; const mockLogger: any = { debug: () => {}, }; - const mockApiCaller = jest.fn().mockResolvedValue({ - body: { - _shards: { - total: 10, - failed: 1, - skipped: 2, - successful: 7, - }, - }, - }); - const mockDeps = ({ - uiSettingsClient: { - get: () => {}, - }, - esClient: { asCurrentUser: { search: mockApiCaller } }, - } as unknown) as SearchStrategyDependencies; + function getMockedDeps(err?: Record) { + mockApiCaller = jest.fn().mockImplementation(() => { + if (err) { + mockedApiCaller = elasticsearchClientMock.createErrorTransportRequestPromise(err); + } else { + mockedApiCaller = elasticsearchClientMock.createSuccessTransportRequestPromise( + successBody, + { statusCode: 200 } + ); + } + return mockedApiCaller; + }); - const mockConfig$ = pluginInitializerContextConfigMock({}).legacy.globalConfig$; + return ({ + uiSettingsClient: { + get: () => {}, + }, + esClient: { asCurrentUser: { search: mockApiCaller } }, + } as unknown) as SearchStrategyDependencies; + } - beforeEach(() => { - mockApiCaller.mockClear(); - }); + const mockConfig$ = pluginInitializerContextConfigMock({}).legacy.globalConfig$; it('returns a strategy with `search`', async () => { const esSearch = await esSearchStrategyProvider(mockConfig$, mockLogger); @@ -48,7 +67,7 @@ describe('ES search strategy', () => { const params = { index: 'logstash-*' }; await esSearchStrategyProvider(mockConfig$, mockLogger) - .search({ params }, {}, mockDeps) + .search({ params }, {}, getMockedDeps()) .subscribe(() => { expect(mockApiCaller).toBeCalled(); expect(mockApiCaller.mock.calls[0][0]).toEqual({ @@ -64,7 +83,7 @@ describe('ES search strategy', () => { const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; await esSearchStrategyProvider(mockConfig$, mockLogger) - .search({ params }, {}, mockDeps) + .search({ params }, {}, getMockedDeps()) .subscribe(() => { expect(mockApiCaller).toBeCalled(); expect(mockApiCaller.mock.calls[0][0]).toEqual({ @@ -82,13 +101,109 @@ describe('ES search strategy', () => { params: { index: 'logstash-*' }, }, {}, - mockDeps + getMockedDeps() ) .subscribe((data) => { expect(data.isRunning).toBe(false); expect(data.isPartial).toBe(false); expect(data).toHaveProperty('loaded'); expect(data).toHaveProperty('rawResponse'); + expect(mockedApiCaller.abort).not.toBeCalled(); done(); })); + + it('can be aborted', async () => { + const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; + + const abortController = new AbortController(); + abortController.abort(); + + await esSearchStrategyProvider(mockConfig$, mockLogger) + .search({ params }, { abortSignal: abortController.signal }, getMockedDeps()) + .toPromise(); + + expect(mockApiCaller).toBeCalled(); + expect(mockApiCaller.mock.calls[0][0]).toEqual({ + ...params, + track_total_hits: true, + }); + expect(mockedApiCaller.abort).toBeCalled(); + }); + + it('throws normalized error if ResponseError is thrown', async (done) => { + const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; + const errResponse = new ResponseError({ + body: indexNotFoundException, + statusCode: 404, + headers: {}, + warnings: [], + meta: {} as any, + }); + + try { + await esSearchStrategyProvider(mockConfig$, mockLogger) + .search({ params }, {}, getMockedDeps(errResponse)) + .toPromise(); + } catch (e) { + expect(mockApiCaller).toBeCalled(); + expect(e).toBeInstanceOf(KbnServerError); + expect(e.statusCode).toBe(404); + expect(e.message).toBe(errResponse.message); + expect(e.errBody).toBe(indexNotFoundException); + done(); + } + }); + + it('throws normalized error if ElasticsearchClientError is thrown', async (done) => { + const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; + const errResponse = new ElasticsearchClientError('This is a general ESClient error'); + + try { + await esSearchStrategyProvider(mockConfig$, mockLogger) + .search({ params }, {}, getMockedDeps(errResponse)) + .toPromise(); + } catch (e) { + expect(mockApiCaller).toBeCalled(); + expect(e).toBeInstanceOf(KbnServerError); + expect(e.statusCode).toBe(500); + expect(e.message).toBe(errResponse.message); + expect(e.errBody).toBe(undefined); + done(); + } + }); + + it('throws normalized error if ESClient throws unknown error', async (done) => { + const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; + const errResponse = new Error('ESClient error'); + + try { + await esSearchStrategyProvider(mockConfig$, mockLogger) + .search({ params }, {}, getMockedDeps(errResponse)) + .toPromise(); + } catch (e) { + expect(mockApiCaller).toBeCalled(); + expect(e).toBeInstanceOf(KbnServerError); + expect(e.statusCode).toBe(500); + expect(e.message).toBe(errResponse.message); + expect(e.errBody).toBe(undefined); + done(); + } + }); + + it('throws KbnServerError for unknown index type', async (done) => { + const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; + + try { + await esSearchStrategyProvider(mockConfig$, mockLogger) + .search({ indexType: 'banana', params }, {}, getMockedDeps()) + .toPromise(); + } catch (e) { + expect(mockApiCaller).not.toBeCalled(); + expect(e).toBeInstanceOf(KbnServerError); + expect(e.message).toBe('Unsupported index pattern type banana'); + expect(e.statusCode).toBe(400); + expect(e.errBody).toBe(undefined); + done(); + } + }); }); diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.ts b/src/plugins/data/server/search/es_search/es_search_strategy.ts index a11bbe11f3f95..c176a50627b92 100644 --- a/src/plugins/data/server/search/es_search/es_search_strategy.ts +++ b/src/plugins/data/server/search/es_search/es_search_strategy.ts @@ -15,13 +15,20 @@ import type { SearchUsage } from '../collectors'; import { getDefaultSearchParams, getShardTimeout, shimAbortSignal } from './request_utils'; import { toKibanaSearchResponse } from './response_utils'; import { searchUsageObserver } from '../collectors/usage'; -import { KbnServerError } from '../../../../kibana_utils/server'; +import { getKbnServerError, KbnServerError } from '../../../../kibana_utils/server'; export const esSearchStrategyProvider = ( config$: Observable, logger: Logger, usage?: SearchUsage ): ISearchStrategy => ({ + /** + * @param request + * @param options + * @param deps + * @throws `KbnServerError` + * @returns `Observable>` + */ search: (request, { abortSignal }, { esClient, uiSettingsClient }) => { // Only default index pattern type is supported here. // See data_enhanced for other type support. @@ -30,15 +37,19 @@ export const esSearchStrategyProvider = ( } const search = async () => { - const config = await config$.pipe(first()).toPromise(); - const params = { - ...(await getDefaultSearchParams(uiSettingsClient)), - ...getShardTimeout(config), - ...request.params, - }; - const promise = esClient.asCurrentUser.search>(params); - const { body } = await shimAbortSignal(promise, abortSignal); - return toKibanaSearchResponse(body); + try { + const config = await config$.pipe(first()).toPromise(); + const params = { + ...(await getDefaultSearchParams(uiSettingsClient)), + ...getShardTimeout(config), + ...request.params, + }; + const promise = esClient.asCurrentUser.search>(params); + const { body } = await shimAbortSignal(promise, abortSignal); + return toKibanaSearchResponse(body); + } catch (e) { + throw getKbnServerError(e); + } }; return from(search()).pipe(tap(searchUsageObserver(logger, usage))); diff --git a/src/plugins/data/server/search/routes/bsearch.ts b/src/plugins/data/server/search/routes/bsearch.ts new file mode 100644 index 0000000000000..e30b7bdaa8402 --- /dev/null +++ b/src/plugins/data/server/search/routes/bsearch.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { catchError, first, map } from 'rxjs/operators'; +import { CoreStart, KibanaRequest } from 'src/core/server'; +import { BfetchServerSetup } from 'src/plugins/bfetch/server'; +import { + IKibanaSearchRequest, + IKibanaSearchResponse, + ISearchClient, + ISearchOptions, +} from '../../../common/search'; +import { shimHitsTotal } from './shim_hits_total'; + +type GetScopedProider = (coreStart: CoreStart) => (request: KibanaRequest) => ISearchClient; + +export function registerBsearchRoute( + bfetch: BfetchServerSetup, + coreStartPromise: Promise<[CoreStart, {}, {}]>, + getScopedProvider: GetScopedProider +): void { + bfetch.addBatchProcessingRoute< + { request: IKibanaSearchRequest; options?: ISearchOptions }, + IKibanaSearchResponse + >('/internal/bsearch', (request) => { + return { + /** + * @param requestOptions + * @throws `KibanaServerError` + */ + onBatchItem: async ({ request: requestData, options }) => { + const coreStart = await coreStartPromise; + const search = getScopedProvider(coreStart[0])(request); + return search + .search(requestData, options) + .pipe( + first(), + map((response) => { + return { + ...response, + ...{ + rawResponse: shimHitsTotal(response.rawResponse), + }, + }; + }), + catchError((err) => { + // Re-throw as object, to get attributes passed to the client + // eslint-disable-next-line no-throw-literal + throw { + message: err.message, + statusCode: err.statusCode, + attributes: err.errBody?.error, + }; + }) + ) + .toPromise(); + }, + }; + }); +} diff --git a/src/plugins/data/server/search/routes/call_msearch.ts b/src/plugins/data/server/search/routes/call_msearch.ts index 6578774f65a3c..fc30e2f29c3ef 100644 --- a/src/plugins/data/server/search/routes/call_msearch.ts +++ b/src/plugins/data/server/search/routes/call_msearch.ts @@ -8,12 +8,12 @@ import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { ApiResponse } from '@elastic/elasticsearch'; import { SearchResponse } from 'elasticsearch'; import { IUiSettingsClient, IScopedClusterClient, SharedGlobalConfig } from 'src/core/server'; import type { MsearchRequestBody, MsearchResponse } from '../../../common/search/search_source'; import { shimHitsTotal } from './shim_hits_total'; +import { getKbnServerError } from '../../../../kibana_utils/server'; import { getShardTimeout, getDefaultSearchParams, shimAbortSignal } from '..'; /** @internal */ @@ -48,6 +48,9 @@ interface CallMsearchDependencies { * @internal */ export function getCallMsearch(dependencies: CallMsearchDependencies) { + /** + * @throws KbnServerError + */ return async (params: { body: MsearchRequestBody; signal?: AbortSignal; @@ -61,28 +64,29 @@ export function getCallMsearch(dependencies: CallMsearchDependencies) { // trackTotalHits is not supported by msearch const { track_total_hits: _, ...defaultParams } = await getDefaultSearchParams(uiSettings); - const body = convertRequestBody(params.body, timeout); - - const promise = shimAbortSignal( - esClient.asCurrentUser.msearch( + try { + const promise = esClient.asCurrentUser.msearch( { - body, + body: convertRequestBody(params.body, timeout), }, { querystring: defaultParams, } - ), - params.signal - ); - const response = (await promise) as ApiResponse<{ responses: Array> }>; + ); + const response = await shimAbortSignal(promise, params.signal); - return { - body: { - ...response, + return { body: { - responses: response.body.responses?.map((r: SearchResponse) => shimHitsTotal(r)), + ...response, + body: { + responses: response.body.responses?.map((r: SearchResponse) => + shimHitsTotal(r) + ), + }, }, - }, - }; + }; + } catch (e) { + throw getKbnServerError(e); + } }; } diff --git a/src/plugins/data/server/search/routes/msearch.test.ts b/src/plugins/data/server/search/routes/msearch.test.ts index 02f200d5435dd..a847931a49123 100644 --- a/src/plugins/data/server/search/routes/msearch.test.ts +++ b/src/plugins/data/server/search/routes/msearch.test.ts @@ -24,6 +24,8 @@ import { convertRequestBody } from './call_msearch'; import { registerMsearchRoute } from './msearch'; import { DataPluginStart } from '../../plugin'; import { dataPluginMock } from '../../mocks'; +import * as jsonEofException from '../../../common/search/test_data/json_e_o_f_exception.json'; +import { ResponseError } from '@elastic/elasticsearch/lib/errors'; describe('msearch route', () => { let mockDataStart: MockedKeys; @@ -76,15 +78,52 @@ describe('msearch route', () => { }); }); - it('handler throws an error if the search throws an error', async () => { - const response = { - message: 'oh no', - body: { - error: 'oops', + it('handler returns an error response if the search throws an error', async () => { + const rejectedValue = Promise.reject( + new ResponseError({ + body: jsonEofException, + statusCode: 400, + meta: {} as any, + headers: [], + warnings: [], + }) + ); + const mockClient = { + msearch: jest.fn().mockReturnValue(rejectedValue), + }; + const mockContext = { + core: { + elasticsearch: { client: { asCurrentUser: mockClient } }, + uiSettings: { client: { get: jest.fn() } }, }, }; + const mockBody = { searches: [{ header: {}, body: {} }] }; + const mockQuery = {}; + const mockRequest = httpServerMock.createKibanaRequest({ + body: mockBody, + query: mockQuery, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + registerMsearchRoute(mockCoreSetup.http.createRouter(), { getStartServices, globalConfig$ }); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.post.mock.calls[0][1]; + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + + expect(mockClient.msearch).toBeCalledTimes(1); + expect(mockResponse.customError).toBeCalled(); + + const error: any = mockResponse.customError.mock.calls[0][0]; + expect(error.statusCode).toBe(400); + expect(error.body.message).toBe('json_e_o_f_exception'); + expect(error.body.attributes).toBe(jsonEofException.error); + }); + + it('handler returns an error response if the search throws a general error', async () => { + const rejectedValue = Promise.reject(new Error('What happened?')); const mockClient = { - msearch: jest.fn().mockReturnValue(Promise.reject(response)), + msearch: jest.fn().mockReturnValue(rejectedValue), }; const mockContext = { core: { @@ -106,11 +145,12 @@ describe('msearch route', () => { const handler = mockRouter.post.mock.calls[0][1]; await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); - expect(mockClient.msearch).toBeCalled(); + expect(mockClient.msearch).toBeCalledTimes(1); expect(mockResponse.customError).toBeCalled(); const error: any = mockResponse.customError.mock.calls[0][0]; - expect(error.body.message).toBe('oh no'); - expect(error.body.attributes.error).toBe('oops'); + expect(error.statusCode).toBe(500); + expect(error.body.message).toBe('What happened?'); + expect(error.body.attributes).toBe(undefined); }); }); diff --git a/src/plugins/data/server/search/routes/search.test.ts b/src/plugins/data/server/search/routes/search.test.ts index f47a42cf9d82b..2cde6d19e4c18 100644 --- a/src/plugins/data/server/search/routes/search.test.ts +++ b/src/plugins/data/server/search/routes/search.test.ts @@ -12,11 +12,27 @@ import { CoreSetup, RequestHandlerContext } from 'src/core/server'; import { coreMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { registerSearchRoute } from './search'; import { DataPluginStart } from '../../plugin'; +import * as searchPhaseException from '../../../common/search/test_data/search_phase_execution_exception.json'; +import * as indexNotFoundException from '../../../common/search/test_data/index_not_found_exception.json'; +import { KbnServerError } from '../../../../kibana_utils/server'; describe('Search service', () => { let mockCoreSetup: MockedKeys>; + function mockEsError(message: string, statusCode: number, attributes?: Record) { + return new KbnServerError(message, statusCode, attributes); + } + + async function runMockSearch(mockContext: any, mockRequest: any, mockResponse: any) { + registerSearchRoute(mockCoreSetup.http.createRouter()); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.post.mock.calls[0][1]; + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + } + beforeEach(() => { + jest.clearAllMocks(); mockCoreSetup = coreMock.createSetup(); }); @@ -54,11 +70,7 @@ describe('Search service', () => { }); const mockResponse = httpServerMock.createResponseFactory(); - registerSearchRoute(mockCoreSetup.http.createRouter()); - - const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; - const handler = mockRouter.post.mock.calls[0][1]; - await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + await runMockSearch(mockContext, mockRequest, mockResponse); expect(mockContext.search.search).toBeCalled(); expect(mockContext.search.search.mock.calls[0][0]).toStrictEqual(mockBody); @@ -68,14 +80,9 @@ describe('Search service', () => { }); }); - it('handler throws an error if the search throws an error', async () => { + it('handler returns an error response if the search throws a painless error', async () => { const rejectedValue = from( - Promise.reject({ - message: 'oh no', - body: { - error: 'oops', - }, - }) + Promise.reject(mockEsError('search_phase_execution_exception', 400, searchPhaseException)) ); const mockContext = { @@ -84,25 +91,69 @@ describe('Search service', () => { }, }; - const mockBody = { id: undefined, params: {} }; - const mockParams = { strategy: 'foo' }; const mockRequest = httpServerMock.createKibanaRequest({ - body: mockBody, - params: mockParams, + body: { id: undefined, params: {} }, + params: { strategy: 'foo' }, }); const mockResponse = httpServerMock.createResponseFactory(); - registerSearchRoute(mockCoreSetup.http.createRouter()); + await runMockSearch(mockContext, mockRequest, mockResponse); - const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; - const handler = mockRouter.post.mock.calls[0][1]; - await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + // verify error + expect(mockResponse.customError).toBeCalled(); + const error: any = mockResponse.customError.mock.calls[0][0]; + expect(error.statusCode).toBe(400); + expect(error.body.message).toBe('search_phase_execution_exception'); + expect(error.body.attributes).toBe(searchPhaseException.error); + }); + + it('handler returns an error response if the search throws an index not found error', async () => { + const rejectedValue = from( + Promise.reject(mockEsError('index_not_found_exception', 404, indexNotFoundException)) + ); + + const mockContext = { + search: { + search: jest.fn().mockReturnValue(rejectedValue), + }, + }; + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { id: undefined, params: {} }, + params: { strategy: 'foo' }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await runMockSearch(mockContext, mockRequest, mockResponse); + + expect(mockResponse.customError).toBeCalled(); + const error: any = mockResponse.customError.mock.calls[0][0]; + expect(error.statusCode).toBe(404); + expect(error.body.message).toBe('index_not_found_exception'); + expect(error.body.attributes).toBe(indexNotFoundException.error); + }); + + it('handler returns an error response if the search throws a general error', async () => { + const rejectedValue = from(Promise.reject(new Error('This is odd'))); + + const mockContext = { + search: { + search: jest.fn().mockReturnValue(rejectedValue), + }, + }; + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { id: undefined, params: {} }, + params: { strategy: 'foo' }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await runMockSearch(mockContext, mockRequest, mockResponse); - expect(mockContext.search.search).toBeCalled(); - expect(mockContext.search.search.mock.calls[0][0]).toStrictEqual(mockBody); expect(mockResponse.customError).toBeCalled(); const error: any = mockResponse.customError.mock.calls[0][0]; - expect(error.body.message).toBe('oh no'); - expect(error.body.attributes.error).toBe('oops'); + expect(error.statusCode).toBe(500); + expect(error.body.message).toBe('This is odd'); + expect(error.body.attributes).toBe(undefined); }); }); diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index f1a6fc09ee21f..63593bbe84a08 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -6,7 +6,7 @@ * Public License, v 1. */ -import { BehaviorSubject, Observable } from 'rxjs'; +import { BehaviorSubject, Observable, throwError } from 'rxjs'; import { pick } from 'lodash'; import { CoreSetup, @@ -18,7 +18,7 @@ import { SharedGlobalConfig, StartServicesAccessor, } from 'src/core/server'; -import { catchError, first, map } from 'rxjs/operators'; +import { first } from 'rxjs/operators'; import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import type { @@ -64,6 +64,7 @@ import { aggShardDelay } from '../../common/search/aggs/buckets/shard_delay_fn'; import { ConfigSchema } from '../../config'; import { SessionService, IScopedSessionService, ISessionService } from './session'; import { KbnServerError } from '../../../kibana_utils/server'; +import { registerBsearchRoute } from './routes/bsearch'; type StrategyMap = Record>; @@ -137,43 +138,7 @@ export class SearchService implements Plugin { ) ); - bfetch.addBatchProcessingRoute< - { request: IKibanaSearchResponse; options?: ISearchOptions }, - any - >('/internal/bsearch', (request) => { - const search = this.asScopedProvider(this.coreStart!)(request); - - return { - onBatchItem: async ({ request: requestData, options }) => { - return search - .search(requestData, options) - .pipe( - first(), - map((response) => { - return { - ...response, - ...{ - rawResponse: shimHitsTotal(response.rawResponse), - }, - }; - }), - catchError((err) => { - // eslint-disable-next-line no-throw-literal - throw { - statusCode: err.statusCode || 500, - body: { - message: err.message, - attributes: { - error: err.body?.error || err.message, - }, - }, - }; - }) - ) - .toPromise(); - }, - }; - }); + registerBsearchRoute(bfetch, core.getStartServices(), this.asScopedProvider); core.savedObjects.registerType(searchTelemetry); if (usageCollection) { @@ -285,10 +250,14 @@ export class SearchService implements Plugin { options: ISearchOptions, deps: SearchStrategyDependencies ) => { - const strategy = this.getSearchStrategy( - options.strategy - ); - return session.search(strategy, request, options, deps); + try { + const strategy = this.getSearchStrategy( + options.strategy + ); + return session.search(strategy, request, options, deps); + } catch (e) { + return throwError(e); + } }; private cancel = (id: string, options: ISearchOptions, deps: SearchStrategyDependencies) => { diff --git a/src/plugins/data/tsconfig.json b/src/plugins/data/tsconfig.json index 81bcb3b02e100..21560b1328840 100644 --- a/src/plugins/data/tsconfig.json +++ b/src/plugins/data/tsconfig.json @@ -7,7 +7,7 @@ "declaration": true, "declarationMap": true }, - "include": ["common/**/*", "public/**/*", "server/**/*", "config.ts"], + "include": ["common/**/*", "public/**/*", "server/**/*", "config.ts", "common/**/*.json"], "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../bfetch/tsconfig.json" }, diff --git a/src/plugins/input_control_vis/tsconfig.json b/src/plugins/input_control_vis/tsconfig.json new file mode 100644 index 0000000000000..bef7bc394a6cc --- /dev/null +++ b/src/plugins/input_control_vis/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "public/**/*", + "server/**/*", + ], + "references": [ + { "path": "../kibana_react/tsconfig.json" }, + { "path": "../data/tsconfig.json"}, + { "path": "../expressions/tsconfig.json" }, + { "path": "../visualizations/tsconfig.json" }, + { "path": "../vis_default_editor/tsconfig.json" }, + ] +} diff --git a/src/plugins/kibana_utils/common/errors/index.ts b/src/plugins/kibana_utils/common/errors/index.ts index 354cf1d504b28..f859e0728269a 100644 --- a/src/plugins/kibana_utils/common/errors/index.ts +++ b/src/plugins/kibana_utils/common/errors/index.ts @@ -7,3 +7,4 @@ */ export * from './errors'; +export * from './types'; diff --git a/src/plugins/kibana_utils/common/errors/types.ts b/src/plugins/kibana_utils/common/errors/types.ts new file mode 100644 index 0000000000000..89e83586dc115 --- /dev/null +++ b/src/plugins/kibana_utils/common/errors/types.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +export interface KibanaServerError { + statusCode: number; + message: string; + attributes?: T; +} diff --git a/src/plugins/kibana_utils/server/index.ts b/src/plugins/kibana_utils/server/index.ts index f95ffe5c3d7b6..821118ea4640d 100644 --- a/src/plugins/kibana_utils/server/index.ts +++ b/src/plugins/kibana_utils/server/index.ts @@ -18,4 +18,4 @@ export { url, } from '../common'; -export { KbnServerError, reportServerError } from './report_server_error'; +export { KbnServerError, reportServerError, getKbnServerError } from './report_server_error'; diff --git a/src/plugins/kibana_utils/server/report_server_error.ts b/src/plugins/kibana_utils/server/report_server_error.ts index 664f34ca7ad51..01e80cfc7184d 100644 --- a/src/plugins/kibana_utils/server/report_server_error.ts +++ b/src/plugins/kibana_utils/server/report_server_error.ts @@ -6,23 +6,42 @@ * Public License, v 1. */ +import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { KibanaResponseFactory } from 'kibana/server'; import { KbnError } from '../common'; export class KbnServerError extends KbnError { - constructor(message: string, public readonly statusCode: number) { + public errBody?: Record; + constructor(message: string, public readonly statusCode: number, errBody?: Record) { super(message); + this.errBody = errBody; } } -export function reportServerError(res: KibanaResponseFactory, err: any) { +/** + * Formats any error thrown into a standardized `KbnServerError`. + * @param e `Error` or `ElasticsearchClientError` + * @returns `KbnServerError` + */ +export function getKbnServerError(e: Error) { + return new KbnServerError( + e.message ?? 'Unknown error', + e instanceof ResponseError ? e.statusCode : 500, + e instanceof ResponseError ? e.body : undefined + ); +} + +/** + * + * @param res Formats a `KbnServerError` into a server error response + * @param err + */ +export function reportServerError(res: KibanaResponseFactory, err: KbnServerError) { return res.customError({ statusCode: err.statusCode ?? 500, body: { message: err.message, - attributes: { - error: err.body?.error || err.message, - }, + attributes: err.errBody?.error, }, }); } diff --git a/src/plugins/legacy_export/tsconfig.json b/src/plugins/legacy_export/tsconfig.json new file mode 100644 index 0000000000000..ec006d492499e --- /dev/null +++ b/src/plugins/legacy_export/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "server/**/*", + ], + "references": [ + { "path": "../../core/tsconfig.json" } + ] +} diff --git a/src/plugins/saved_objects_management/common/index.ts b/src/plugins/saved_objects_management/common/index.ts index a8395e602979c..8850899e38958 100644 --- a/src/plugins/saved_objects_management/common/index.ts +++ b/src/plugins/saved_objects_management/common/index.ts @@ -6,4 +6,11 @@ * Public License, v 1. */ -export { SavedObjectRelation, SavedObjectWithMetadata, SavedObjectMetadata } from './types'; +export { + SavedObjectWithMetadata, + SavedObjectMetadata, + SavedObjectRelation, + SavedObjectRelationKind, + SavedObjectInvalidRelation, + SavedObjectGetRelationshipsResponse, +} from './types'; diff --git a/src/plugins/saved_objects_management/common/types.ts b/src/plugins/saved_objects_management/common/types.ts index 8618cf4332acf..e100dfc6b23e6 100644 --- a/src/plugins/saved_objects_management/common/types.ts +++ b/src/plugins/saved_objects_management/common/types.ts @@ -28,12 +28,26 @@ export type SavedObjectWithMetadata = SavedObject & { meta: SavedObjectMetadata; }; +export type SavedObjectRelationKind = 'child' | 'parent'; + /** * Represents a relation between two {@link SavedObject | saved object} */ export interface SavedObjectRelation { id: string; type: string; - relationship: 'child' | 'parent'; + relationship: SavedObjectRelationKind; meta: SavedObjectMetadata; } + +export interface SavedObjectInvalidRelation { + id: string; + type: string; + relationship: SavedObjectRelationKind; + error: string; +} + +export interface SavedObjectGetRelationshipsResponse { + relations: SavedObjectRelation[]; + invalidRelations: SavedObjectInvalidRelation[]; +} diff --git a/src/plugins/saved_objects_management/public/lib/get_relationships.test.ts b/src/plugins/saved_objects_management/public/lib/get_relationships.test.ts index b609fac67dac1..4454907f530fe 100644 --- a/src/plugins/saved_objects_management/public/lib/get_relationships.test.ts +++ b/src/plugins/saved_objects_management/public/lib/get_relationships.test.ts @@ -6,6 +6,7 @@ * Public License, v 1. */ +import { SavedObjectGetRelationshipsResponse } from '../types'; import { httpServiceMock } from '../../../../core/public/mocks'; import { getRelationships } from './get_relationships'; @@ -22,13 +23,17 @@ describe('getRelationships', () => { }); it('should handle successful responses', async () => { - httpMock.get.mockResolvedValue([1, 2]); + const serverResponse: SavedObjectGetRelationshipsResponse = { + relations: [], + invalidRelations: [], + }; + httpMock.get.mockResolvedValue(serverResponse); const response = await getRelationships(httpMock, 'dashboard', '1', [ 'search', 'index-pattern', ]); - expect(response).toEqual([1, 2]); + expect(response).toEqual(serverResponse); }); it('should handle errors', async () => { diff --git a/src/plugins/saved_objects_management/public/lib/get_relationships.ts b/src/plugins/saved_objects_management/public/lib/get_relationships.ts index 0eb97e1052fa4..69aeb6fbf580b 100644 --- a/src/plugins/saved_objects_management/public/lib/get_relationships.ts +++ b/src/plugins/saved_objects_management/public/lib/get_relationships.ts @@ -8,19 +8,19 @@ import { HttpStart } from 'src/core/public'; import { get } from 'lodash'; -import { SavedObjectRelation } from '../types'; +import { SavedObjectGetRelationshipsResponse } from '../types'; export async function getRelationships( http: HttpStart, type: string, id: string, savedObjectTypes: string[] -): Promise { +): Promise { const url = `/api/kibana/management/saved_objects/relationships/${encodeURIComponent( type )}/${encodeURIComponent(id)}`; try { - return await http.get(url, { + return await http.get(url, { query: { savedObjectTypes, }, diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap index 15e5cb89b622c..c39263f304249 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap @@ -28,133 +28,131 @@ exports[`Relationships should render dashboards normally 1`] = ` -
- -

- Here are the saved objects related to MyDashboard. Deleting this dashboard affects its parent objects, but not its children. -

-
- - +

+ Here are the saved objects related to MyDashboard. Deleting this dashboard affects its parent objects, but not its children. +

+ + + -
+ } + tableLayout="fixed" + />
`; @@ -231,138 +229,315 @@ exports[`Relationships should render index patterns normally 1`] = ` -
- -

- Here are the saved objects related to MyIndexPattern*. Deleting this index-pattern affects its parent objects, but not its children. -

-
- - +

+ Here are the saved objects related to MyIndexPattern*. Deleting this index-pattern affects its parent objects, but not its children. +

+ + + + + +`; + +exports[`Relationships should render invalid relations 1`] = ` + + + +

+ + + +    + MyIndexPattern* +

+
+
+ + + + + + +

+ Here are the saved objects related to MyIndexPattern*. Deleting this index-pattern affects its parent objects, but not its children. +

+
+ + -
+ } + tableLayout="fixed" + />
`; @@ -395,138 +570,136 @@ exports[`Relationships should render searches normally 1`] = ` -
- -

- Here are the saved objects related to MySearch. Deleting this search affects its parent objects, but not its children. -

-
- - +

+ Here are the saved objects related to MySearch. Deleting this search affects its parent objects, but not its children. +

+ + + -
+ } + tableLayout="fixed" + />
`; @@ -559,133 +732,131 @@ exports[`Relationships should render visualizations normally 1`] = ` -
- -

- Here are the saved objects related to MyViz. Deleting this visualization affects its parent objects, but not its children. -

-
- - +

+ Here are the saved objects related to MyViz. Deleting this visualization affects its parent objects, but not its children. +

+ + + -
+ } + tableLayout="fixed" + />
`; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx index 72a4b0f2788fa..e590520193bba 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx @@ -25,36 +25,39 @@ describe('Relationships', () => { goInspectObject: () => {}, canGoInApp: () => true, basePath: httpServiceMock.createSetupContract().basePath, - getRelationships: jest.fn().mockImplementation(() => [ - { - type: 'search', - id: '1', - relationship: 'parent', - meta: { - editUrl: '/management/kibana/objects/savedSearches/1', - icon: 'search', - inAppUrl: { - path: '/app/discover#//1', - uiCapabilitiesPath: 'discover.show', + getRelationships: jest.fn().mockImplementation(() => ({ + relations: [ + { + type: 'search', + id: '1', + relationship: 'parent', + meta: { + editUrl: '/management/kibana/objects/savedSearches/1', + icon: 'search', + inAppUrl: { + path: '/app/discover#//1', + uiCapabilitiesPath: 'discover.show', + }, + title: 'My Search Title', }, - title: 'My Search Title', }, - }, - { - type: 'visualization', - id: '2', - relationship: 'parent', - meta: { - editUrl: '/management/kibana/objects/savedVisualizations/2', - icon: 'visualizeApp', - inAppUrl: { - path: '/app/visualize#/edit/2', - uiCapabilitiesPath: 'visualize.show', + { + type: 'visualization', + id: '2', + relationship: 'parent', + meta: { + editUrl: '/management/kibana/objects/savedVisualizations/2', + icon: 'visualizeApp', + inAppUrl: { + path: '/app/visualize#/edit/2', + uiCapabilitiesPath: 'visualize.show', + }, + title: 'My Visualization Title', }, - title: 'My Visualization Title', }, - }, - ]), + ], + invalidRelations: [], + })), savedObject: { id: '1', type: 'index-pattern', @@ -92,36 +95,39 @@ describe('Relationships', () => { goInspectObject: () => {}, canGoInApp: () => true, basePath: httpServiceMock.createSetupContract().basePath, - getRelationships: jest.fn().mockImplementation(() => [ - { - type: 'index-pattern', - id: '1', - relationship: 'child', - meta: { - editUrl: '/management/kibana/indexPatterns/patterns/1', - icon: 'indexPatternApp', - inAppUrl: { - path: '/app/management/kibana/indexPatterns/patterns/1', - uiCapabilitiesPath: 'management.kibana.indexPatterns', + getRelationships: jest.fn().mockImplementation(() => ({ + relations: [ + { + type: 'index-pattern', + id: '1', + relationship: 'child', + meta: { + editUrl: '/management/kibana/indexPatterns/patterns/1', + icon: 'indexPatternApp', + inAppUrl: { + path: '/app/management/kibana/indexPatterns/patterns/1', + uiCapabilitiesPath: 'management.kibana.indexPatterns', + }, + title: 'My Index Pattern', }, - title: 'My Index Pattern', }, - }, - { - type: 'visualization', - id: '2', - relationship: 'parent', - meta: { - editUrl: '/management/kibana/objects/savedVisualizations/2', - icon: 'visualizeApp', - inAppUrl: { - path: '/app/visualize#/edit/2', - uiCapabilitiesPath: 'visualize.show', + { + type: 'visualization', + id: '2', + relationship: 'parent', + meta: { + editUrl: '/management/kibana/objects/savedVisualizations/2', + icon: 'visualizeApp', + inAppUrl: { + path: '/app/visualize#/edit/2', + uiCapabilitiesPath: 'visualize.show', + }, + title: 'My Visualization Title', }, - title: 'My Visualization Title', }, - }, - ]), + ], + invalidRelations: [], + })), savedObject: { id: '1', type: 'search', @@ -159,36 +165,39 @@ describe('Relationships', () => { goInspectObject: () => {}, canGoInApp: () => true, basePath: httpServiceMock.createSetupContract().basePath, - getRelationships: jest.fn().mockImplementation(() => [ - { - type: 'dashboard', - id: '1', - relationship: 'parent', - meta: { - editUrl: '/management/kibana/objects/savedDashboards/1', - icon: 'dashboardApp', - inAppUrl: { - path: '/app/kibana#/dashboard/1', - uiCapabilitiesPath: 'dashboard.show', + getRelationships: jest.fn().mockImplementation(() => ({ + relations: [ + { + type: 'dashboard', + id: '1', + relationship: 'parent', + meta: { + editUrl: '/management/kibana/objects/savedDashboards/1', + icon: 'dashboardApp', + inAppUrl: { + path: '/app/kibana#/dashboard/1', + uiCapabilitiesPath: 'dashboard.show', + }, + title: 'My Dashboard 1', }, - title: 'My Dashboard 1', }, - }, - { - type: 'dashboard', - id: '2', - relationship: 'parent', - meta: { - editUrl: '/management/kibana/objects/savedDashboards/2', - icon: 'dashboardApp', - inAppUrl: { - path: '/app/kibana#/dashboard/2', - uiCapabilitiesPath: 'dashboard.show', + { + type: 'dashboard', + id: '2', + relationship: 'parent', + meta: { + editUrl: '/management/kibana/objects/savedDashboards/2', + icon: 'dashboardApp', + inAppUrl: { + path: '/app/kibana#/dashboard/2', + uiCapabilitiesPath: 'dashboard.show', + }, + title: 'My Dashboard 2', }, - title: 'My Dashboard 2', }, - }, - ]), + ], + invalidRelations: [], + })), savedObject: { id: '1', type: 'visualization', @@ -226,36 +235,39 @@ describe('Relationships', () => { goInspectObject: () => {}, canGoInApp: () => true, basePath: httpServiceMock.createSetupContract().basePath, - getRelationships: jest.fn().mockImplementation(() => [ - { - type: 'visualization', - id: '1', - relationship: 'child', - meta: { - editUrl: '/management/kibana/objects/savedVisualizations/1', - icon: 'visualizeApp', - inAppUrl: { - path: '/app/visualize#/edit/1', - uiCapabilitiesPath: 'visualize.show', + getRelationships: jest.fn().mockImplementation(() => ({ + relations: [ + { + type: 'visualization', + id: '1', + relationship: 'child', + meta: { + editUrl: '/management/kibana/objects/savedVisualizations/1', + icon: 'visualizeApp', + inAppUrl: { + path: '/app/visualize#/edit/1', + uiCapabilitiesPath: 'visualize.show', + }, + title: 'My Visualization Title 1', }, - title: 'My Visualization Title 1', }, - }, - { - type: 'visualization', - id: '2', - relationship: 'child', - meta: { - editUrl: '/management/kibana/objects/savedVisualizations/2', - icon: 'visualizeApp', - inAppUrl: { - path: '/app/visualize#/edit/2', - uiCapabilitiesPath: 'visualize.show', + { + type: 'visualization', + id: '2', + relationship: 'child', + meta: { + editUrl: '/management/kibana/objects/savedVisualizations/2', + icon: 'visualizeApp', + inAppUrl: { + path: '/app/visualize#/edit/2', + uiCapabilitiesPath: 'visualize.show', + }, + title: 'My Visualization Title 2', }, - title: 'My Visualization Title 2', }, - }, - ]), + ], + invalidRelations: [], + })), savedObject: { id: '1', type: 'dashboard', @@ -324,4 +336,49 @@ describe('Relationships', () => { expect(props.getRelationships).toHaveBeenCalled(); expect(component).toMatchSnapshot(); }); + + it('should render invalid relations', async () => { + const props: RelationshipsProps = { + goInspectObject: () => {}, + canGoInApp: () => true, + basePath: httpServiceMock.createSetupContract().basePath, + getRelationships: jest.fn().mockImplementation(() => ({ + relations: [], + invalidRelations: [ + { + id: '1', + type: 'dashboard', + relationship: 'child', + error: 'Saved object [dashboard/1] not found', + }, + ], + })), + savedObject: { + id: '1', + type: 'index-pattern', + attributes: {}, + references: [], + meta: { + title: 'MyIndexPattern*', + icon: 'indexPatternApp', + editUrl: '#/management/kibana/indexPatterns/patterns/1', + inAppUrl: { + path: '/management/kibana/indexPatterns/patterns/1', + uiCapabilitiesPath: 'management.kibana.indexPatterns', + }, + }, + }, + close: jest.fn(), + }; + + const component = shallowWithI18nProvider(); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(props.getRelationships).toHaveBeenCalled(); + expect(component).toMatchSnapshot(); + }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx index 2d62699b6f1f2..aee61f7bc9c7a 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx @@ -26,11 +26,17 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { IBasePath } from 'src/core/public'; import { getDefaultTitle, getSavedObjectLabel } from '../../../lib'; -import { SavedObjectWithMetadata, SavedObjectRelation } from '../../../types'; +import { + SavedObjectWithMetadata, + SavedObjectRelationKind, + SavedObjectRelation, + SavedObjectInvalidRelation, + SavedObjectGetRelationshipsResponse, +} from '../../../types'; export interface RelationshipsProps { basePath: IBasePath; - getRelationships: (type: string, id: string) => Promise; + getRelationships: (type: string, id: string) => Promise; savedObject: SavedObjectWithMetadata; close: () => void; goInspectObject: (obj: SavedObjectWithMetadata) => void; @@ -38,17 +44,47 @@ export interface RelationshipsProps { } export interface RelationshipsState { - relationships: SavedObjectRelation[]; + relations: SavedObjectRelation[]; + invalidRelations: SavedObjectInvalidRelation[]; isLoading: boolean; error?: string; } +const relationshipColumn = { + field: 'relationship', + name: i18n.translate('savedObjectsManagement.objectsTable.relationships.columnRelationshipName', { + defaultMessage: 'Direct relationship', + }), + dataType: 'string', + sortable: false, + width: '125px', + 'data-test-subj': 'directRelationship', + render: (relationship: SavedObjectRelationKind) => { + return ( + + {relationship === 'parent' ? ( + + ) : ( + + )} + + ); + }, +}; + export class Relationships extends Component { constructor(props: RelationshipsProps) { super(props); this.state = { - relationships: [], + relations: [], + invalidRelations: [], isLoading: false, error: undefined, }; @@ -70,8 +106,11 @@ export class Relationships extends Component + + + ({ + 'data-test-subj': `invalidRelationshipsTableRow`, + })} + /> + + + ); + } + + renderRelationshipsTable() { + const { goInspectObject, basePath, savedObject } = this.props; + const { relations, isLoading, error } = this.state; if (error) { return this.renderError(); @@ -137,39 +250,7 @@ export class Relationships extends Component { - if (relationship === 'parent') { - return ( - - - - ); - } - if (relationship === 'child') { - return ( - - - - ); - } - }, - }, + relationshipColumn, { field: 'meta.title', name: i18n.translate('savedObjectsManagement.objectsTable.relationships.columnTitleName', { @@ -224,7 +305,7 @@ export class Relationships extends Component [ + relations.map((relationship) => [ relationship.type, { value: relationship.type, @@ -277,7 +358,7 @@ export class Relationships extends Component + <>

{i18n.translate( @@ -296,7 +377,7 @@ export class Relationships extends Component -

+ ); } @@ -328,8 +409,10 @@ export class Relationships extends Component - - {this.renderRelationships()} + + {this.renderInvalidRelationship()} + {this.renderRelationshipsTable()} + ); } diff --git a/src/plugins/saved_objects_management/public/types.ts b/src/plugins/saved_objects_management/public/types.ts index 37f239227475d..cdfa3c43e5af2 100644 --- a/src/plugins/saved_objects_management/public/types.ts +++ b/src/plugins/saved_objects_management/public/types.ts @@ -6,4 +6,11 @@ * Public License, v 1. */ -export { SavedObjectMetadata, SavedObjectWithMetadata, SavedObjectRelation } from '../common'; +export { + SavedObjectMetadata, + SavedObjectWithMetadata, + SavedObjectRelationKind, + SavedObjectRelation, + SavedObjectInvalidRelation, + SavedObjectGetRelationshipsResponse, +} from '../common'; diff --git a/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts b/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts index 631faf0c23c98..416be7d7e7426 100644 --- a/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts +++ b/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts @@ -6,10 +6,35 @@ * Public License, v 1. */ +import type { SavedObject, SavedObjectError } from 'src/core/types'; +import type { SavedObjectsFindResponse } from 'src/core/server'; import { findRelationships } from './find_relationships'; import { managementMock } from '../services/management.mock'; import { savedObjectsClientMock } from '../../../../core/server/mocks'; +const createObj = (parts: Partial>): SavedObject => ({ + id: 'id', + type: 'type', + attributes: {}, + references: [], + ...parts, +}); + +const createFindResponse = (objs: SavedObject[]): SavedObjectsFindResponse => ({ + saved_objects: objs.map((obj) => ({ ...obj, score: 1 })), + total: objs.length, + per_page: 20, + page: 1, +}); + +const createError = (parts: Partial): SavedObjectError => ({ + error: 'error', + message: 'message', + metadata: {}, + statusCode: 404, + ...parts, +}); + describe('findRelationships', () => { let savedObjectsClient: ReturnType; let managementService: ReturnType; @@ -19,7 +44,7 @@ describe('findRelationships', () => { managementService = managementMock.create(); }); - it('returns the child and parent references of the object', async () => { + it('calls the savedObjectClient APIs with the correct parameters', async () => { const type = 'dashboard'; const id = 'some-id'; const references = [ @@ -36,46 +61,35 @@ describe('findRelationships', () => { ]; const referenceTypes = ['some-type', 'another-type']; - savedObjectsClient.get.mockResolvedValue({ - id, - type, - attributes: {}, - references, - }); - + savedObjectsClient.get.mockResolvedValue( + createObj({ + id, + type, + references, + }) + ); savedObjectsClient.bulkGet.mockResolvedValue({ saved_objects: [ - { + createObj({ type: 'some-type', id: 'ref-1', - attributes: {}, - references: [], - }, - { + }), + createObj({ type: 'another-type', id: 'ref-2', - attributes: {}, - references: [], - }, + }), ], }); - - savedObjectsClient.find.mockResolvedValue({ - saved_objects: [ - { + savedObjectsClient.find.mockResolvedValue( + createFindResponse([ + createObj({ type: 'parent-type', id: 'parent-id', - attributes: {}, - score: 1, - references: [], - }, - ], - total: 1, - per_page: 20, - page: 1, - }); + }), + ]) + ); - const relationships = await findRelationships({ + await findRelationships({ type, id, size: 20, @@ -101,8 +115,63 @@ describe('findRelationships', () => { perPage: 20, type: referenceTypes, }); + }); + + it('returns the child and parent references of the object', async () => { + const type = 'dashboard'; + const id = 'some-id'; + const references = [ + { + type: 'some-type', + id: 'ref-1', + name: 'ref 1', + }, + { + type: 'another-type', + id: 'ref-2', + name: 'ref 2', + }, + ]; + const referenceTypes = ['some-type', 'another-type']; + + savedObjectsClient.get.mockResolvedValue( + createObj({ + id, + type, + references, + }) + ); + savedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [ + createObj({ + type: 'some-type', + id: 'ref-1', + }), + createObj({ + type: 'another-type', + id: 'ref-2', + }), + ], + }); + savedObjectsClient.find.mockResolvedValue( + createFindResponse([ + createObj({ + type: 'parent-type', + id: 'parent-id', + }), + ]) + ); + + const { relations, invalidRelations } = await findRelationships({ + type, + id, + size: 20, + client: savedObjectsClient, + referenceTypes, + savedObjectsManagement: managementService, + }); - expect(relationships).toEqual([ + expect(relations).toEqual([ { id: 'ref-1', relationship: 'child', @@ -122,6 +191,70 @@ describe('findRelationships', () => { meta: expect.any(Object), }, ]); + expect(invalidRelations).toHaveLength(0); + }); + + it('returns the invalid relations', async () => { + const type = 'dashboard'; + const id = 'some-id'; + const references = [ + { + type: 'some-type', + id: 'ref-1', + name: 'ref 1', + }, + { + type: 'another-type', + id: 'ref-2', + name: 'ref 2', + }, + ]; + const referenceTypes = ['some-type', 'another-type']; + + savedObjectsClient.get.mockResolvedValue( + createObj({ + id, + type, + references, + }) + ); + const ref1Error = createError({ message: 'Not found' }); + savedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [ + createObj({ + type: 'some-type', + id: 'ref-1', + error: ref1Error, + }), + createObj({ + type: 'another-type', + id: 'ref-2', + }), + ], + }); + savedObjectsClient.find.mockResolvedValue(createFindResponse([])); + + const { relations, invalidRelations } = await findRelationships({ + type, + id, + size: 20, + client: savedObjectsClient, + referenceTypes, + savedObjectsManagement: managementService, + }); + + expect(relations).toEqual([ + { + id: 'ref-2', + relationship: 'child', + type: 'another-type', + meta: expect.any(Object), + }, + ]); + + expect(invalidRelations).toEqual([ + { type: 'some-type', id: 'ref-1', relationship: 'child', error: ref1Error.message }, + ]); }); it('uses the management service to consolidate the relationship objects', async () => { @@ -144,32 +277,24 @@ describe('findRelationships', () => { uiCapabilitiesPath: 'uiCapabilitiesPath', }); - savedObjectsClient.get.mockResolvedValue({ - id, - type, - attributes: {}, - references, - }); - + savedObjectsClient.get.mockResolvedValue( + createObj({ + id, + type, + references, + }) + ); savedObjectsClient.bulkGet.mockResolvedValue({ saved_objects: [ - { + createObj({ type: 'some-type', id: 'ref-1', - attributes: {}, - references: [], - }, + }), ], }); + savedObjectsClient.find.mockResolvedValue(createFindResponse([])); - savedObjectsClient.find.mockResolvedValue({ - saved_objects: [], - total: 0, - per_page: 20, - page: 1, - }); - - const relationships = await findRelationships({ + const { relations } = await findRelationships({ type, id, size: 20, @@ -183,7 +308,7 @@ describe('findRelationships', () => { expect(managementService.getEditUrl).toHaveBeenCalledTimes(1); expect(managementService.getInAppUrl).toHaveBeenCalledTimes(1); - expect(relationships).toEqual([ + expect(relations).toEqual([ { id: 'ref-1', relationship: 'child', diff --git a/src/plugins/saved_objects_management/server/lib/find_relationships.ts b/src/plugins/saved_objects_management/server/lib/find_relationships.ts index 0ceef484196a3..bc6568e73c4e2 100644 --- a/src/plugins/saved_objects_management/server/lib/find_relationships.ts +++ b/src/plugins/saved_objects_management/server/lib/find_relationships.ts @@ -9,7 +9,11 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { injectMetaAttributes } from './inject_meta_attributes'; import { ISavedObjectsManagement } from '../services'; -import { SavedObjectRelation, SavedObjectWithMetadata } from '../types'; +import { + SavedObjectInvalidRelation, + SavedObjectWithMetadata, + SavedObjectGetRelationshipsResponse, +} from '../types'; export async function findRelationships({ type, @@ -25,17 +29,19 @@ export async function findRelationships({ client: SavedObjectsClientContract; referenceTypes: string[]; savedObjectsManagement: ISavedObjectsManagement; -}): Promise { +}): Promise { const { references = [] } = await client.get(type, id); // Use a map to avoid duplicates, it does happen but have a different "name" in the reference - const referencedToBulkGetOpts = new Map( - references.map((ref) => [`${ref.type}:${ref.id}`, { id: ref.id, type: ref.type }]) - ); + const childrenReferences = [ + ...new Map( + references.map((ref) => [`${ref.type}:${ref.id}`, { id: ref.id, type: ref.type }]) + ).values(), + ]; const [childReferencesResponse, parentReferencesResponse] = await Promise.all([ - referencedToBulkGetOpts.size > 0 - ? client.bulkGet([...referencedToBulkGetOpts.values()]) + childrenReferences.length > 0 + ? client.bulkGet(childrenReferences) : Promise.resolve({ saved_objects: [] }), client.find({ hasReference: { type, id }, @@ -44,28 +50,37 @@ export async function findRelationships({ }), ]); - return childReferencesResponse.saved_objects - .map((obj) => injectMetaAttributes(obj, savedObjectsManagement)) - .map(extractCommonProperties) - .map( - (obj) => - ({ - ...obj, - relationship: 'child', - } as SavedObjectRelation) - ) - .concat( - parentReferencesResponse.saved_objects - .map((obj) => injectMetaAttributes(obj, savedObjectsManagement)) - .map(extractCommonProperties) - .map( - (obj) => - ({ - ...obj, - relationship: 'parent', - } as SavedObjectRelation) - ) - ); + const invalidRelations: SavedObjectInvalidRelation[] = childReferencesResponse.saved_objects + .filter((obj) => Boolean(obj.error)) + .map((obj) => ({ + id: obj.id, + type: obj.type, + relationship: 'child', + error: obj.error!.message, + })); + + const relations = [ + ...childReferencesResponse.saved_objects + .filter((obj) => !obj.error) + .map((obj) => injectMetaAttributes(obj, savedObjectsManagement)) + .map(extractCommonProperties) + .map((obj) => ({ + ...obj, + relationship: 'child' as const, + })), + ...parentReferencesResponse.saved_objects + .map((obj) => injectMetaAttributes(obj, savedObjectsManagement)) + .map(extractCommonProperties) + .map((obj) => ({ + ...obj, + relationship: 'parent' as const, + })), + ]; + + return { + relations, + invalidRelations, + }; } function extractCommonProperties(savedObject: SavedObjectWithMetadata) { diff --git a/src/plugins/saved_objects_management/server/routes/relationships.ts b/src/plugins/saved_objects_management/server/routes/relationships.ts index 3a52c973fde8d..5417ff2926120 100644 --- a/src/plugins/saved_objects_management/server/routes/relationships.ts +++ b/src/plugins/saved_objects_management/server/routes/relationships.ts @@ -38,7 +38,7 @@ export const registerRelationshipsRoute = ( ? req.query.savedObjectTypes : [req.query.savedObjectTypes]; - const relations = await findRelationships({ + const findRelationsResponse = await findRelationships({ type, id, client, @@ -48,7 +48,7 @@ export const registerRelationshipsRoute = ( }); return res.ok({ - body: relations, + body: findRelationsResponse, }); }) ); diff --git a/src/plugins/saved_objects_management/server/types.ts b/src/plugins/saved_objects_management/server/types.ts index 710bb5db7d1cb..562970d2d2dcd 100644 --- a/src/plugins/saved_objects_management/server/types.ts +++ b/src/plugins/saved_objects_management/server/types.ts @@ -12,4 +12,11 @@ export interface SavedObjectsManagementPluginSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SavedObjectsManagementPluginStart {} -export { SavedObjectMetadata, SavedObjectWithMetadata, SavedObjectRelation } from '../common'; +export { + SavedObjectMetadata, + SavedObjectWithMetadata, + SavedObjectRelationKind, + SavedObjectRelation, + SavedObjectInvalidRelation, + SavedObjectGetRelationshipsResponse, +} from '../common'; diff --git a/test/accessibility/apps/kibana_overview.ts b/test/accessibility/apps/kibana_overview.ts index c26a042b10e72..eb0b54ad07aa7 100644 --- a/test/accessibility/apps/kibana_overview.ts +++ b/test/accessibility/apps/kibana_overview.ts @@ -16,7 +16,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); before(async () => { - await esArchiver.load('empty_kibana'); + await esArchiver.emptyKibanaIndex(); await PageObjects.common.navigateToApp('kibanaOverview'); }); @@ -25,7 +25,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { useActualUrl: true, }); await PageObjects.home.removeSampleDataSet('flights'); - await esArchiver.unload('empty_kibana'); }); it('Getting started view', async () => { diff --git a/test/api_integration/apis/home/sample_data.ts b/test/api_integration/apis/home/sample_data.ts index 042aff1375267..ebda93b12dc20 100644 --- a/test/api_integration/apis/home/sample_data.ts +++ b/test/api_integration/apis/home/sample_data.ts @@ -11,11 +11,15 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); const es = getService('es'); const MILLISECOND_IN_WEEK = 1000 * 60 * 60 * 24 * 7; describe('sample data apis', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + }); describe('list', () => { it('should return list of sample data sets with installed status', async () => { const resp = await supertest.get(`/api/sample_data`).set('kbn-xsrf', 'kibana').expect(200); diff --git a/test/api_integration/apis/saved_objects/bulk_create.ts b/test/api_integration/apis/saved_objects/bulk_create.ts index a548172365b07..d7cdee16214a8 100644 --- a/test/api_integration/apis/saved_objects/bulk_create.ts +++ b/test/api_integration/apis/saved_objects/bulk_create.ts @@ -97,10 +97,11 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); - it('should return 200 with individual responses', async () => + it('should return 200 with errors', async () => { + await new Promise((resolve) => setTimeout(resolve, 2000)); await supertest .post('/api/saved_objects/_bulk_create') .send(BULK_REQUESTS) @@ -109,38 +110,27 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body).to.eql({ saved_objects: [ { - type: 'visualization', - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - updated_at: resp.body.saved_objects[0].updated_at, - version: resp.body.saved_objects[0].version, - attributes: { - title: 'An existing visualization', - }, - references: [], - namespaces: ['default'], - migrationVersion: { - visualization: resp.body.saved_objects[0].migrationVersion.visualization, + id: BULK_REQUESTS[0].id, + type: BULK_REQUESTS[0].type, + error: { + error: 'Internal Server Error', + message: 'An internal server error occurred', + statusCode: 500, }, - coreMigrationVersion: KIBANA_VERSION, // updated from 1.2.3 to the latest kibana version }, { - type: 'dashboard', - id: 'a01b2f57-fcfd-4864-b735-09e28f0d815e', - updated_at: resp.body.saved_objects[1].updated_at, - version: resp.body.saved_objects[1].version, - attributes: { - title: 'A great new dashboard', - }, - references: [], - namespaces: ['default'], - migrationVersion: { - dashboard: resp.body.saved_objects[1].migrationVersion.dashboard, + id: BULK_REQUESTS[1].id, + type: BULK_REQUESTS[1].type, + error: { + error: 'Internal Server Error', + message: 'An internal server error occurred', + statusCode: 500, }, - coreMigrationVersion: KIBANA_VERSION, }, ], }); - })); + }); + }); }); }); } diff --git a/test/api_integration/apis/saved_objects/bulk_get.ts b/test/api_integration/apis/saved_objects/bulk_get.ts index 46631225f8e8a..b9536843d30c9 100644 --- a/test/api_integration/apis/saved_objects/bulk_get.ts +++ b/test/api_integration/apis/saved_objects/bulk_get.ts @@ -108,7 +108,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); it('should return 200 with individual responses', async () => diff --git a/test/api_integration/apis/saved_objects/bulk_update.ts b/test/api_integration/apis/saved_objects/bulk_update.ts index 5a2496b6dde81..2cf3ade406a93 100644 --- a/test/api_integration/apis/saved_objects/bulk_update.ts +++ b/test/api_integration/apis/saved_objects/bulk_update.ts @@ -235,10 +235,10 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); - it('should return generic 404', async () => { + it('should return 200 with errors', async () => { const response = await supertest .put(`/api/saved_objects/_bulk_update`) .send([ @@ -267,9 +267,9 @@ export default function ({ getService }: FtrProviderContext) { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', type: 'visualization', error: { - statusCode: 404, - error: 'Not Found', - message: 'Saved object [visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab] not found', + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', }, }); @@ -277,9 +277,9 @@ export default function ({ getService }: FtrProviderContext) { id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', type: 'dashboard', error: { - statusCode: 404, - error: 'Not Found', - message: 'Saved object [dashboard/be3733a0-9efe-11e7-acb3-3dab96693fab] not found', + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', }, }); }); diff --git a/test/api_integration/apis/saved_objects/create.ts b/test/api_integration/apis/saved_objects/create.ts index 551e082630e8f..833cb127d0023 100644 --- a/test/api_integration/apis/saved_objects/create.ts +++ b/test/api_integration/apis/saved_objects/create.ts @@ -82,10 +82,10 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); - it('should return 200 and create kibana index', async () => { + it('should return 500 and not auto-create saved objects index', async () => { await supertest .post(`/api/saved_objects/visualization`) .send({ @@ -93,50 +93,16 @@ export default function ({ getService }: FtrProviderContext) { title: 'My favorite vis', }, }) - .expect(200) + .expect(500) .then((resp) => { - // loose uuid validation - expect(resp.body) - .to.have.property('id') - .match(/^[0-9a-f-]{36}$/); - - // loose ISO8601 UTC time with milliseconds validation - expect(resp.body) - .to.have.property('updated_at') - .match(/^[\d-]{10}T[\d:\.]{12}Z$/); - expect(resp.body).to.eql({ - id: resp.body.id, - type: 'visualization', - migrationVersion: resp.body.migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - updated_at: resp.body.updated_at, - version: resp.body.version, - attributes: { - title: 'My favorite vis', - }, - references: [], - namespaces: ['default'], + error: 'Internal Server Error', + message: 'An internal server error occurred.', + statusCode: 500, }); - expect(resp.body.migrationVersion).to.be.ok(); }); - expect((await es.indices.exists({ index: '.kibana' })).body).to.be(true); - }); - - it('result should have the latest coreMigrationVersion', async () => { - await supertest - .post(`/api/saved_objects/visualization`) - .send({ - attributes: { - title: 'My favorite vis', - }, - coreMigrationVersion: '1.2.3', - }) - .expect(200) - .then((resp) => { - expect(resp.body.coreMigrationVersion).to.eql(KIBANA_VERSION); - }); + expect((await es.indices.exists({ index: '.kibana' })).body).to.be(false); }); }); }); diff --git a/test/api_integration/apis/saved_objects/delete.ts b/test/api_integration/apis/saved_objects/delete.ts index 9ba51b4b91468..d2dd4454bdf1e 100644 --- a/test/api_integration/apis/saved_objects/delete.ts +++ b/test/api_integration/apis/saved_objects/delete.ts @@ -44,7 +44,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); it('returns generic 404 when kibana index is missing', async () => diff --git a/test/api_integration/apis/saved_objects/export.ts b/test/api_integration/apis/saved_objects/export.ts index a45191f24d872..c0d5430070951 100644 --- a/test/api_integration/apis/saved_objects/export.ts +++ b/test/api_integration/apis/saved_objects/export.ts @@ -534,7 +534,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); it('should return empty response', async () => { diff --git a/test/api_integration/apis/saved_objects/find.ts b/test/api_integration/apis/saved_objects/find.ts index 7aa4de86baa69..5f549dc6c5780 100644 --- a/test/api_integration/apis/saved_objects/find.ts +++ b/test/api_integration/apis/saved_objects/find.ts @@ -40,7 +40,7 @@ export default function ({ getService }: FtrProviderContext) { { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzIsMV0=', + version: 'WzE4LDJd', attributes: { title: 'Count of requests', }, @@ -137,7 +137,7 @@ export default function ({ getService }: FtrProviderContext) { { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzIsMV0=', + version: 'WzE4LDJd', attributes: { title: 'Count of requests', }, @@ -174,7 +174,7 @@ export default function ({ getService }: FtrProviderContext) { { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzIsMV0=', + version: 'WzE4LDJd', attributes: { title: 'Count of requests', }, @@ -209,7 +209,7 @@ export default function ({ getService }: FtrProviderContext) { score: 0, type: 'visualization', updated_at: '2017-09-21T18:51:23.794Z', - version: 'WzYsMV0=', + version: 'WzIyLDJd', }, ], }); @@ -256,7 +256,7 @@ export default function ({ getService }: FtrProviderContext) { migrationVersion: resp.body.saved_objects[0].migrationVersion, coreMigrationVersion: KIBANA_VERSION, updated_at: '2017-09-21T18:51:23.794Z', - version: 'WzIsMV0=', + version: 'WzE4LDJd', }, ], }); @@ -426,11 +426,11 @@ export default function ({ getService }: FtrProviderContext) { })); }); - describe.skip('without kibana index', () => { + describe('without kibana index', () => { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); it('should return 200 with empty response', async () => diff --git a/test/api_integration/apis/saved_objects/get.ts b/test/api_integration/apis/saved_objects/get.ts index ff47b9df218dc..9bb6e32004c81 100644 --- a/test/api_integration/apis/saved_objects/get.ts +++ b/test/api_integration/apis/saved_objects/get.ts @@ -78,7 +78,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); it('should return basic 404 without mentioning index', async () => diff --git a/test/api_integration/apis/saved_objects/resolve_import_errors.ts b/test/api_integration/apis/saved_objects/resolve_import_errors.ts index 3686c46b229b1..042741476bb8e 100644 --- a/test/api_integration/apis/saved_objects/resolve_import_errors.ts +++ b/test/api_integration/apis/saved_objects/resolve_import_errors.ts @@ -13,6 +13,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); + const es = getService('legacyEs'); describe('resolve_import_errors', () => { // mock success results including metadata @@ -34,7 +35,14 @@ export default function ({ getService }: FtrProviderContext) { describe('without kibana index', () => { // Cleanup data that got created in import - after(() => esArchiver.unload('saved_objects/basic')); + before( + async () => + // just in case the kibana server has recreated it + await es.indices.delete({ + index: '.kibana*', + ignore: [404], + }) + ); it('should return 200 and import nothing when empty parameters are passed in', async () => { await supertest @@ -51,7 +59,7 @@ export default function ({ getService }: FtrProviderContext) { }); }); - it('should return 200 and import everything when overwrite parameters contains all objects', async () => { + it('should return 200 with internal server errors', async () => { await supertest .post('/api/saved_objects/_resolve_import_errors') .field( @@ -78,12 +86,42 @@ export default function ({ getService }: FtrProviderContext) { .expect(200) .then((resp) => { expect(resp.body).to.eql({ - success: true, - successCount: 3, - successResults: [ - { ...indexPattern, overwrite: true }, - { ...visualization, overwrite: true }, - { ...dashboard, overwrite: true }, + successCount: 0, + success: false, + errors: [ + { + ...indexPattern, + ...{ title: indexPattern.meta.title }, + overwrite: true, + error: { + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', + type: 'unknown', + }, + }, + { + ...visualization, + ...{ title: visualization.meta.title }, + overwrite: true, + error: { + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', + type: 'unknown', + }, + }, + { + ...dashboard, + ...{ title: dashboard.meta.title }, + overwrite: true, + error: { + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', + type: 'unknown', + }, + }, ], warnings: [], }); diff --git a/test/api_integration/apis/saved_objects/update.ts b/test/api_integration/apis/saved_objects/update.ts index d5346e82ce98c..da7285a430fdd 100644 --- a/test/api_integration/apis/saved_objects/update.ts +++ b/test/api_integration/apis/saved_objects/update.ts @@ -121,10 +121,10 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); - it('should return generic 404', async () => + it('should return 500', async () => await supertest .put(`/api/saved_objects/visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab`) .send({ @@ -132,13 +132,12 @@ export default function ({ getService }: FtrProviderContext) { title: 'My second favorite vis', }, }) - .expect(404) + .expect(500) .then((resp) => { expect(resp.body).eql({ - statusCode: 404, - error: 'Not Found', - message: - 'Saved object [visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab] not found', + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred.', }); })); }); diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index acc01c73de674..8453b542903a4 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -42,7 +42,7 @@ export default function ({ getService }: FtrProviderContext) { { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzIsMV0=', + version: 'WzE4LDJd', attributes: { title: 'Count of requests', }, @@ -184,7 +184,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); it('should return 200 with empty response', async () => diff --git a/test/api_integration/apis/saved_objects_management/get.ts b/test/api_integration/apis/saved_objects_management/get.ts index bc05d7e392bb9..70e1faa9fd22b 100644 --- a/test/api_integration/apis/saved_objects_management/get.ts +++ b/test/api_integration/apis/saved_objects_management/get.ts @@ -45,7 +45,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); it('should return 404 for object that no longer exists', async () => diff --git a/test/api_integration/apis/saved_objects_management/relationships.ts b/test/api_integration/apis/saved_objects_management/relationships.ts index 185c6ded01de4..6dea461f790e8 100644 --- a/test/api_integration/apis/saved_objects_management/relationships.ts +++ b/test/api_integration/apis/saved_objects_management/relationships.ts @@ -14,23 +14,32 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const responseSchema = schema.arrayOf( - schema.object({ - id: schema.string(), - type: schema.string(), - relationship: schema.oneOf([schema.literal('parent'), schema.literal('child')]), - meta: schema.object({ - title: schema.string(), - icon: schema.string(), - editUrl: schema.string(), - inAppUrl: schema.object({ - path: schema.string(), - uiCapabilitiesPath: schema.string(), - }), - namespaceType: schema.string(), + const relationSchema = schema.object({ + id: schema.string(), + type: schema.string(), + relationship: schema.oneOf([schema.literal('parent'), schema.literal('child')]), + meta: schema.object({ + title: schema.string(), + icon: schema.string(), + editUrl: schema.string(), + inAppUrl: schema.object({ + path: schema.string(), + uiCapabilitiesPath: schema.string(), }), - }) - ); + namespaceType: schema.string(), + }), + }); + const invalidRelationSchema = schema.object({ + id: schema.string(), + type: schema.string(), + relationship: schema.oneOf([schema.literal('parent'), schema.literal('child')]), + error: schema.string(), + }); + + const responseSchema = schema.object({ + relations: schema.arrayOf(relationSchema), + invalidRelations: schema.arrayOf(invalidRelationSchema), + }); describe('relationships', () => { before(async () => { @@ -64,7 +73,7 @@ export default function ({ getService }: FtrProviderContext) { .get(relationshipsUrl('search', '960372e0-3224-11e8-a572-ffca06da1357')) .expect(200); - expect(resp.body).to.eql([ + expect(resp.body.relations).to.eql([ { id: '8963ca30-3224-11e8-a572-ffca06da1357', type: 'index-pattern', @@ -108,7 +117,7 @@ export default function ({ getService }: FtrProviderContext) { ) .expect(200); - expect(resp.body).to.eql([ + expect(resp.body.relations).to.eql([ { id: '8963ca30-3224-11e8-a572-ffca06da1357', type: 'index-pattern', @@ -145,8 +154,7 @@ export default function ({ getService }: FtrProviderContext) { ]); }); - // TODO: https://github.com/elastic/kibana/issues/19713 causes this test to fail. - it.skip('should return 404 if search finds no results', async () => { + it('should return 404 if search finds no results', async () => { await supertest .get(relationshipsUrl('search', 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')) .expect(404); @@ -169,7 +177,7 @@ export default function ({ getService }: FtrProviderContext) { .get(relationshipsUrl('dashboard', 'b70c7ae0-3224-11e8-a572-ffca06da1357')) .expect(200); - expect(resp.body).to.eql([ + expect(resp.body.relations).to.eql([ { id: 'add810b0-3224-11e8-a572-ffca06da1357', type: 'visualization', @@ -210,7 +218,7 @@ export default function ({ getService }: FtrProviderContext) { .get(relationshipsUrl('dashboard', 'b70c7ae0-3224-11e8-a572-ffca06da1357', ['search'])) .expect(200); - expect(resp.body).to.eql([ + expect(resp.body.relations).to.eql([ { id: 'add810b0-3224-11e8-a572-ffca06da1357', type: 'visualization', @@ -246,8 +254,7 @@ export default function ({ getService }: FtrProviderContext) { ]); }); - // TODO: https://github.com/elastic/kibana/issues/19713 causes this test to fail. - it.skip('should return 404 if dashboard finds no results', async () => { + it('should return 404 if dashboard finds no results', async () => { await supertest .get(relationshipsUrl('dashboard', 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')) .expect(404); @@ -270,7 +277,7 @@ export default function ({ getService }: FtrProviderContext) { .get(relationshipsUrl('visualization', 'a42c0580-3224-11e8-a572-ffca06da1357')) .expect(200); - expect(resp.body).to.eql([ + expect(resp.body.relations).to.eql([ { id: '960372e0-3224-11e8-a572-ffca06da1357', type: 'search', @@ -313,7 +320,7 @@ export default function ({ getService }: FtrProviderContext) { ) .expect(200); - expect(resp.body).to.eql([ + expect(resp.body.relations).to.eql([ { id: '960372e0-3224-11e8-a572-ffca06da1357', type: 'search', @@ -356,7 +363,7 @@ export default function ({ getService }: FtrProviderContext) { .get(relationshipsUrl('index-pattern', '8963ca30-3224-11e8-a572-ffca06da1357')) .expect(200); - expect(resp.body).to.eql([ + expect(resp.body.relations).to.eql([ { id: '960372e0-3224-11e8-a572-ffca06da1357', type: 'search', @@ -399,7 +406,7 @@ export default function ({ getService }: FtrProviderContext) { ) .expect(200); - expect(resp.body).to.eql([ + expect(resp.body.relations).to.eql([ { id: '960372e0-3224-11e8-a572-ffca06da1357', type: 'search', @@ -425,5 +432,48 @@ export default function ({ getService }: FtrProviderContext) { .expect(404); }); }); + + describe('invalid references', () => { + it('should validate the response schema', async () => { + const resp = await supertest.get(relationshipsUrl('dashboard', 'invalid-refs')).expect(200); + + expect(() => { + responseSchema.validate(resp.body); + }).not.to.throwError(); + }); + + it('should return the invalid relations', async () => { + const resp = await supertest.get(relationshipsUrl('dashboard', 'invalid-refs')).expect(200); + + expect(resp.body).to.eql({ + invalidRelations: [ + { + error: 'Saved object [visualization/invalid-vis] not found', + id: 'invalid-vis', + relationship: 'child', + type: 'visualization', + }, + ], + relations: [ + { + id: 'add810b0-3224-11e8-a572-ffca06da1357', + meta: { + editUrl: + '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + icon: 'visualizeApp', + inAppUrl: { + path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', + uiCapabilitiesPath: 'visualize.show', + }, + namespaceType: 'single', + title: 'Visualization', + }, + relationship: 'child', + type: 'visualization', + }, + ], + }); + }); + }); }); } diff --git a/test/api_integration/apis/search/bsearch.ts b/test/api_integration/apis/search/bsearch.ts new file mode 100644 index 0000000000000..504680d28bf83 --- /dev/null +++ b/test/api_integration/apis/search/bsearch.ts @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import expect from '@kbn/expect'; +import request from 'superagent'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { painlessErrReq } from './painless_err_req'; +import { verifyErrorResponse } from './verify_error'; + +function parseBfetchResponse(resp: request.Response): Array> { + return resp.text + .trim() + .split('\n') + .map((item) => JSON.parse(item)); +} + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('bsearch', () => { + describe('post', () => { + it('should return 200 a single response', async () => { + const resp = await supertest.post(`/internal/bsearch`).send({ + batch: [ + { + request: { + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }, + }, + ], + }); + + const jsonBody = JSON.parse(resp.text); + + expect(resp.status).to.be(200); + expect(jsonBody.id).to.be(0); + expect(jsonBody.result.isPartial).to.be(false); + expect(jsonBody.result.isRunning).to.be(false); + expect(jsonBody.result).to.have.property('rawResponse'); + }); + + it('should return a batch of successful resposes', async () => { + const resp = await supertest.post(`/internal/bsearch`).send({ + batch: [ + { + request: { + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }, + }, + { + request: { + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }, + }, + ], + }); + + expect(resp.status).to.be(200); + const parsedResponse = parseBfetchResponse(resp); + expect(parsedResponse).to.have.length(2); + parsedResponse.forEach((responseJson) => { + expect(responseJson.result.isPartial).to.be(false); + expect(responseJson.result.isRunning).to.be(false); + expect(responseJson.result).to.have.property('rawResponse'); + }); + }); + + it('should return error for not found strategy', async () => { + const resp = await supertest.post(`/internal/bsearch`).send({ + batch: [ + { + request: { + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }, + options: { + strategy: 'wtf', + }, + }, + ], + }); + + expect(resp.status).to.be(200); + parseBfetchResponse(resp).forEach((responseJson, i) => { + expect(responseJson.id).to.be(i); + verifyErrorResponse(responseJson.error, 404, 'Search strategy wtf not found'); + }); + }); + + it('should return 400 when index type is provided in OSS', async () => { + const resp = await supertest.post(`/internal/bsearch`).send({ + batch: [ + { + request: { + indexType: 'baad', + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }, + }, + ], + }); + + expect(resp.status).to.be(200); + parseBfetchResponse(resp).forEach((responseJson, i) => { + expect(responseJson.id).to.be(i); + verifyErrorResponse(responseJson.error, 400, 'Unsupported index pattern type baad'); + }); + }); + + describe('painless', () => { + before(async () => { + await esArchiver.loadIfNeeded( + '../../../functional/fixtures/es_archiver/logstash_functional' + ); + }); + + after(async () => { + await esArchiver.unload('../../../functional/fixtures/es_archiver/logstash_functional'); + }); + it('should return 400 for Painless error', async () => { + const resp = await supertest.post(`/internal/bsearch`).send({ + batch: [ + { + request: painlessErrReq, + }, + ], + }); + + expect(resp.status).to.be(200); + parseBfetchResponse(resp).forEach((responseJson, i) => { + expect(responseJson.id).to.be(i); + verifyErrorResponse(responseJson.error, 400, 'search_phase_execution_exception', true); + }); + }); + }); + }); + }); +} diff --git a/test/api_integration/apis/search/index.ts b/test/api_integration/apis/search/index.ts index 2f21825d6902f..6e90bf0f22c51 100644 --- a/test/api_integration/apis/search/index.ts +++ b/test/api_integration/apis/search/index.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('search', () => { loadTestFile(require.resolve('./search')); + loadTestFile(require.resolve('./bsearch')); loadTestFile(require.resolve('./msearch')); }); } diff --git a/test/api_integration/apis/search/painless_err_req.ts b/test/api_integration/apis/search/painless_err_req.ts new file mode 100644 index 0000000000000..6fbf6565d7a9e --- /dev/null +++ b/test/api_integration/apis/search/painless_err_req.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +export const painlessErrReq = { + params: { + index: 'log*', + body: { + size: 500, + fields: ['*'], + script_fields: { + invalid_scripted_field: { + script: { + source: 'invalid', + lang: 'painless', + }, + }, + }, + stored_fields: ['*'], + query: { + bool: { + filter: [ + { + match_all: {}, + }, + { + range: { + '@timestamp': { + gte: '2015-01-19T12:27:55.047Z', + lte: '2021-01-19T12:27:55.047Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + }, + }, +}; diff --git a/test/api_integration/apis/search/search.ts b/test/api_integration/apis/search/search.ts index fc13189a40753..e43c449309306 100644 --- a/test/api_integration/apis/search/search.ts +++ b/test/api_integration/apis/search/search.ts @@ -8,11 +8,22 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { painlessErrReq } from './painless_err_req'; +import { verifyErrorResponse } from './verify_error'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('search', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + await esArchiver.loadIfNeeded('../../../functional/fixtures/es_archiver/logstash_functional'); + }); + + after(async () => { + await esArchiver.unload('../../../functional/fixtures/es_archiver/logstash_functional'); + }); describe('post', () => { it('should return 200 when correctly formatted searches are provided', async () => { const resp = await supertest @@ -28,13 +39,37 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(200); + expect(resp.status).to.be(200); expect(resp.body.isPartial).to.be(false); expect(resp.body.isRunning).to.be(false); expect(resp.body).to.have.property('rawResponse'); }); - it('should return 404 when if no strategy is provided', async () => - await supertest + it('should return 200 if terminated early', async () => { + const resp = await supertest + .post(`/internal/search/es`) + .send({ + params: { + terminateAfter: 1, + index: 'log*', + size: 1000, + body: { + query: { + match_all: {}, + }, + }, + }, + }) + .expect(200); + + expect(resp.status).to.be(200); + expect(resp.body.isPartial).to.be(false); + expect(resp.body.isRunning).to.be(false); + expect(resp.body.rawResponse.terminated_early).to.be(true); + }); + + it('should return 404 when if no strategy is provided', async () => { + const resp = await supertest .post(`/internal/search`) .send({ body: { @@ -43,7 +78,10 @@ export default function ({ getService }: FtrProviderContext) { }, }, }) - .expect(404)); + .expect(404); + + verifyErrorResponse(resp.body, 404); + }); it('should return 404 when if unknown strategy is provided', async () => { const resp = await supertest @@ -56,6 +94,8 @@ export default function ({ getService }: FtrProviderContext) { }, }) .expect(404); + + verifyErrorResponse(resp.body, 404); expect(resp.body.message).to.contain('banana not found'); }); @@ -74,11 +114,33 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(400); + verifyErrorResponse(resp.body, 400); + expect(resp.body.message).to.contain('Unsupported index pattern'); }); + it('should return 400 with illegal ES argument', async () => { + const resp = await supertest + .post(`/internal/search/es`) + .send({ + params: { + timeout: 1, // This should be a time range string! + index: 'log*', + size: 1000, + body: { + query: { + match_all: {}, + }, + }, + }, + }) + .expect(400); + + verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true); + }); + it('should return 400 with a bad body', async () => { - await supertest + const resp = await supertest .post(`/internal/search/es`) .send({ params: { @@ -89,16 +151,26 @@ export default function ({ getService }: FtrProviderContext) { }, }) .expect(400); + + verifyErrorResponse(resp.body, 400, 'parsing_exception', true); + }); + + it('should return 400 for a painless error', async () => { + const resp = await supertest.post(`/internal/search/es`).send(painlessErrReq).expect(400); + + verifyErrorResponse(resp.body, 400, 'search_phase_execution_exception', true); }); }); describe('delete', () => { it('should return 404 when no search id provided', async () => { - await supertest.delete(`/internal/search/es`).send().expect(404); + const resp = await supertest.delete(`/internal/search/es`).send().expect(404); + verifyErrorResponse(resp.body, 404); }); it('should return 400 when trying a delete on a non supporting strategy', async () => { const resp = await supertest.delete(`/internal/search/es/123`).send().expect(400); + verifyErrorResponse(resp.body, 400); expect(resp.body.message).to.contain("Search strategy es doesn't support cancellations"); }); }); diff --git a/test/api_integration/apis/search/verify_error.ts b/test/api_integration/apis/search/verify_error.ts new file mode 100644 index 0000000000000..a5754ff47973e --- /dev/null +++ b/test/api_integration/apis/search/verify_error.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import expect from '@kbn/expect'; + +export const verifyErrorResponse = ( + r: any, + expectedCode: number, + message?: string, + shouldHaveAttrs?: boolean +) => { + expect(r.statusCode).to.be(expectedCode); + if (message) { + expect(r.message).to.be(message); + } + if (shouldHaveAttrs) { + expect(r).to.have.property('attributes'); + expect(r.attributes).to.have.property('root_cause'); + } else { + expect(r).not.to.have.property('attributes'); + } +}; diff --git a/test/api_integration/apis/telemetry/opt_in.ts b/test/api_integration/apis/telemetry/opt_in.ts index f03b33e61965e..ba5f46c38211f 100644 --- a/test/api_integration/apis/telemetry/opt_in.ts +++ b/test/api_integration/apis/telemetry/opt_in.ts @@ -14,10 +14,13 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function optInTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const kibanaServer = getService('kibanaServer'); + const esArchiver = getService('esArchiver'); + describe('/api/telemetry/v2/optIn API', () => { let defaultAttributes: TelemetrySavedObjectAttributes; let kibanaVersion: any; before(async () => { + await esArchiver.emptyKibanaIndex(); const kibanaVersionAccessor = kibanaServer.version; kibanaVersion = await kibanaVersionAccessor.get(); defaultAttributes = diff --git a/test/api_integration/apis/telemetry/telemetry_local.ts b/test/api_integration/apis/telemetry/telemetry_local.ts index 25d29a807bdad..650846015a4a2 100644 --- a/test/api_integration/apis/telemetry/telemetry_local.ts +++ b/test/api_integration/apis/telemetry/telemetry_local.ts @@ -177,6 +177,7 @@ export default function ({ getService }: FtrProviderContext) { describe('basic behaviour', () => { let savedObjectIds: string[] = []; before('create application usage entries', async () => { + await esArchiver.emptyKibanaIndex(); savedObjectIds = await Promise.all([ createSavedObject(), createSavedObject('appView1'), diff --git a/test/api_integration/apis/ui_counters/ui_counters.ts b/test/api_integration/apis/ui_counters/ui_counters.ts index 1cf16fe433bf9..8d60c79c9698d 100644 --- a/test/api_integration/apis/ui_counters/ui_counters.ts +++ b/test/api_integration/apis/ui_counters/ui_counters.ts @@ -13,6 +13,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); const es = getService('es'); const createUiCounterEvent = (eventName: string, type: UiCounterMetricType, count = 1) => ({ @@ -23,6 +24,10 @@ export default function ({ getService }: FtrProviderContext) { }); describe('UI Counters API', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + }); + const dayDate = moment().format('DDMMYYYY'); it('stores ui counter events in savedObjects', async () => { diff --git a/test/api_integration/apis/ui_metric/ui_metric.ts b/test/api_integration/apis/ui_metric/ui_metric.ts index d330cb037d1a1..e3b3b2ec4c542 100644 --- a/test/api_integration/apis/ui_metric/ui_metric.ts +++ b/test/api_integration/apis/ui_metric/ui_metric.ts @@ -13,6 +13,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); const es = getService('es'); const createStatsMetric = ( @@ -34,6 +35,10 @@ export default function ({ getService }: FtrProviderContext) { }); describe('ui_metric savedObject data', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + }); + it('increments the count field in the document defined by the {app}/{action_type} path', async () => { const reportManager = new ReportManager(); const uiStatsMetric = createStatsMetric('myEvent'); diff --git a/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/data.json b/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/data.json new file mode 100644 index 0000000000000..21d84c4b55e55 --- /dev/null +++ b/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/data.json @@ -0,0 +1,190 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "timelion-sheet:190f3e90-2ec3-11e8-ba48-69fc4e41e1f6", + "source": { + "type": "timelion-sheet", + "updated_at": "2018-03-23T17:53:30.872Z", + "timelion-sheet": { + "title": "New TimeLion Sheet", + "hits": 0, + "description": "", + "timelion_sheet": [ + ".es(*)" + ], + "timelion_interval": "auto", + "timelion_chart_height": 275, + "timelion_columns": 2, + "timelion_rows": 2, + "version": 1 + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "index-pattern:8963ca30-3224-11e8-a572-ffca06da1357", + "source": { + "type": "index-pattern", + "updated_at": "2018-03-28T01:08:34.290Z", + "index-pattern": { + "title": "saved_objects*", + "fields": "[{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]" + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "config:7.0.0-alpha1", + "source": { + "type": "config", + "updated_at": "2018-03-28T01:08:39.248Z", + "config": { + "buildNum": 8467, + "telemetry:optIn": false, + "defaultIndex": "8963ca30-3224-11e8-a572-ffca06da1357" + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "search:960372e0-3224-11e8-a572-ffca06da1357", + "source": { + "type": "search", + "updated_at": "2018-03-28T01:08:55.182Z", + "search": { + "title": "OneRecord", + "description": "", + "hits": 0, + "columns": [ + "_source" + ], + "sort": [ + "_score", + "desc" + ], + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"8963ca30-3224-11e8-a572-ffca06da1357\",\"highlightAll\":true,\"version\":true,\"query\":{\"query\":\"id:3\",\"language\":\"lucene\"},\"filter\":[]}" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:a42c0580-3224-11e8-a572-ffca06da1357", + "source": { + "type": "visualization", + "updated_at": "2018-03-28T01:09:18.936Z", + "visualization": { + "title": "VisualizationFromSavedSearch", + "visState": "{\"title\":\"VisualizationFromSavedSearch\",\"type\":\"table\",\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMeticsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}}]}", + "uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}", + "description": "", + "savedSearchId": "960372e0-3224-11e8-a572-ffca06da1357", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:add810b0-3224-11e8-a572-ffca06da1357", + "source": { + "type": "visualization", + "updated_at": "2018-03-28T01:09:35.163Z", + "visualization": { + "title": "Visualization", + "visState": "{\"title\":\"Visualization\",\"type\":\"metric\",\"params\":{\"addTooltip\":true,\"addLegend\":false,\"type\":\"metric\",\"metric\":{\"percentageMode\":false,\"useRanges\":false,\"colorSchema\":\"Green to Red\",\"metricColorMode\":\"None\",\"colorsRange\":[{\"from\":0,\"to\":10000}],\"labels\":{\"show\":true},\"invertColors\":false,\"style\":{\"bgFill\":\"#000\",\"bgColor\":false,\"labelColor\":false,\"subText\":\"\",\"fontSize\":60}}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}}]}", + "uiStateJSON": "{}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"8963ca30-3224-11e8-a572-ffca06da1357\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "dashboard:b70c7ae0-3224-11e8-a572-ffca06da1357", + "source": { + "type": "dashboard", + "updated_at": "2018-03-28T01:09:50.606Z", + "dashboard": { + "title": "Dashboard", + "hits": 0, + "description": "", + "panelsJSON": "[{\"gridData\":{\"w\":24,\"h\":15,\"x\":0,\"y\":0,\"i\":\"1\"},\"version\":\"7.0.0-alpha1\",\"panelIndex\":\"1\",\"type\":\"visualization\",\"id\":\"add810b0-3224-11e8-a572-ffca06da1357\",\"embeddableConfig\":{}},{\"gridData\":{\"w\":24,\"h\":15,\"x\":24,\"y\":0,\"i\":\"2\"},\"version\":\"7.0.0-alpha1\",\"panelIndex\":\"2\",\"type\":\"visualization\",\"id\":\"a42c0580-3224-11e8-a572-ffca06da1357\",\"embeddableConfig\":{}}]", + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "version": 1, + "timeRestore": false, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "dashboard:invalid-refs", + "source": { + "type": "dashboard", + "updated_at": "2018-03-28T01:09:50.606Z", + "dashboard": { + "title": "Dashboard", + "hits": 0, + "description": "", + "panelsJSON": "[]", + "optionsJSON": "{}", + "version": 1, + "timeRestore": false, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + } + }, + "references": [ + { + "type":"visualization", + "id": "add810b0-3224-11e8-a572-ffca06da1357", + "name": "valid-ref" + }, + { + "type":"visualization", + "id": "invalid-vis", + "name": "missing-ref" + } + ] + } + } +} diff --git a/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/data.json.gz b/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/data.json.gz deleted file mode 100644 index 0834567abb66b..0000000000000 Binary files a/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/data.json.gz and /dev/null differ diff --git a/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/mappings.json b/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/mappings.json index c670508247b1a..6dd4d198e0f67 100644 --- a/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/mappings.json +++ b/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/mappings.json @@ -12,6 +12,20 @@ "mappings": { "dynamic": "strict", "properties": { + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, "config": { "dynamic": "true", "properties": { @@ -280,4 +294,4 @@ } } } -} \ No newline at end of file +} diff --git a/test/common/config.js b/test/common/config.js index b6d12444b7017..8a42e6c87b214 100644 --- a/test/common/config.js +++ b/test/common/config.js @@ -61,8 +61,6 @@ export default function () { ...(!!process.env.CODE_COVERAGE ? [`--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'coverage')}`] : []), - // Disable v2 migrations in tests for now - '--migrations.enableV2=false', ], }, services, diff --git a/test/common/services/kibana_server/extend_es_archiver.js b/test/common/services/kibana_server/extend_es_archiver.js index 5390b43a87187..1d76bc4473767 100644 --- a/test/common/services/kibana_server/extend_es_archiver.js +++ b/test/common/services/kibana_server/extend_es_archiver.js @@ -6,7 +6,7 @@ * Public License, v 1. */ -const ES_ARCHIVER_LOAD_METHODS = ['load', 'loadIfNeeded', 'unload']; +const ES_ARCHIVER_LOAD_METHODS = ['load', 'loadIfNeeded', 'unload', 'emptyKibanaIndex']; const KIBANA_INDEX = '.kibana'; export function extendEsArchiver({ esArchiver, kibanaServer, retry, defaults }) { @@ -25,7 +25,7 @@ export function extendEsArchiver({ esArchiver, kibanaServer, retry, defaults }) const statsKeys = Object.keys(stats); const kibanaKeys = statsKeys.filter( // this also matches stats keys like '.kibana_1' and '.kibana_2,.kibana_1' - (key) => key.includes(KIBANA_INDEX) && (stats[key].created || stats[key].deleted) + (key) => key.includes(KIBANA_INDEX) && stats[key].created ); // if the kibana index was created by the esArchiver then update the uiSettings diff --git a/test/functional/apps/management/_import_objects.ts b/test/functional/apps/management/_import_objects.ts index 754406938e47b..e18f2a7485444 100644 --- a/test/functional/apps/management/_import_objects.ts +++ b/test/functional/apps/management/_import_objects.ts @@ -27,9 +27,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe.skip('import objects', function describeIndexTests() { describe('.ndjson file', () => { beforeEach(async function () { + await esArchiver.load('management'); await kibanaServer.uiSettings.replace({}); await PageObjects.settings.navigateTo(); - await esArchiver.load('management'); await PageObjects.settings.clickKibanaSavedObjects(); }); @@ -213,10 +213,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('.json file', () => { beforeEach(async function () { - // delete .kibana index and then wait for Kibana to re-create it + await esArchiver.load('saved_objects_imports'); await kibanaServer.uiSettings.replace({}); await PageObjects.settings.navigateTo(); - await esArchiver.load('saved_objects_imports'); await PageObjects.settings.clickKibanaSavedObjects(); }); diff --git a/test/functional/apps/management/_index_pattern_filter.js b/test/functional/apps/management/_index_pattern_filter.js index eae53682b6ccf..91ea13348d611 100644 --- a/test/functional/apps/management/_index_pattern_filter.js +++ b/test/functional/apps/management/_index_pattern_filter.js @@ -12,10 +12,11 @@ export default function ({ getService, getPageObjects }) { const kibanaServer = getService('kibanaServer'); const retry = getService('retry'); const PageObjects = getPageObjects(['settings']); + const esArchiver = getService('esArchiver'); describe('index pattern filter', function describeIndexTests() { before(async function () { - // delete .kibana index and then wait for Kibana to re-create it + await esArchiver.emptyKibanaIndex(); await kibanaServer.uiSettings.replace({}); await PageObjects.settings.navigateTo(); await PageObjects.settings.clickKibanaIndexPatterns(); diff --git a/test/functional/apps/management/_index_patterns_empty.ts b/test/functional/apps/management/_index_patterns_empty.ts index 3b89e05d4b582..4e86de6d70653 100644 --- a/test/functional/apps/management/_index_patterns_empty.ts +++ b/test/functional/apps/management/_index_patterns_empty.ts @@ -19,7 +19,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('index pattern empty view', () => { before(async () => { - await esArchiver.load('empty_kibana'); + await esArchiver.emptyKibanaIndex(); await esArchiver.unload('logstash_functional'); await esArchiver.unload('makelogs'); await kibanaServer.uiSettings.replace({}); @@ -27,7 +27,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); after(async () => { - await esArchiver.unload('empty_kibana'); await esArchiver.loadIfNeeded('makelogs'); // @ts-expect-error await es.transport.request({ diff --git a/test/functional/apps/management/_mgmt_import_saved_objects.js b/test/functional/apps/management/_mgmt_import_saved_objects.js index 45a18d5932764..87eca2c7a5a65 100644 --- a/test/functional/apps/management/_mgmt_import_saved_objects.js +++ b/test/functional/apps/management/_mgmt_import_saved_objects.js @@ -18,14 +18,13 @@ export default function ({ getService, getPageObjects }) { describe('mgmt saved objects', function describeIndexTests() { beforeEach(async function () { - await esArchiver.load('empty_kibana'); + await esArchiver.emptyKibanaIndex(); await esArchiver.load('discover'); await PageObjects.settings.navigateTo(); }); afterEach(async function () { await esArchiver.unload('discover'); - await esArchiver.load('empty_kibana'); }); it('should import saved objects mgmt', async function () { diff --git a/test/functional/apps/management/_test_huge_fields.js b/test/functional/apps/management/_test_huge_fields.js index 2ab619276d2b9..c1fca31e695cb 100644 --- a/test/functional/apps/management/_test_huge_fields.js +++ b/test/functional/apps/management/_test_huge_fields.js @@ -20,6 +20,7 @@ export default function ({ getService, getPageObjects }) { const EXPECTED_FIELD_COUNT = '10006'; before(async function () { await security.testUser.setRoles(['kibana_admin', 'test_testhuge_reader']); + await esArchiver.emptyKibanaIndex(); await esArchiver.loadIfNeeded('large_fields'); await PageObjects.settings.createIndexPattern('testhuge', 'date'); }); diff --git a/test/functional/apps/management/index.ts b/test/functional/apps/management/index.ts index ca89853875027..06e652f9f3e59 100644 --- a/test/functional/apps/management/index.ts +++ b/test/functional/apps/management/index.ts @@ -14,13 +14,11 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { describe('management', function () { before(async () => { await esArchiver.unload('logstash_functional'); - await esArchiver.load('empty_kibana'); await esArchiver.loadIfNeeded('makelogs'); }); after(async () => { await esArchiver.unload('makelogs'); - await esArchiver.unload('empty_kibana'); }); describe('', function () { diff --git a/test/functional/apps/saved_objects_management/index.ts b/test/functional/apps/saved_objects_management/index.ts index 9491661de73ef..5e4eaefb7e9d1 100644 --- a/test/functional/apps/saved_objects_management/index.ts +++ b/test/functional/apps/saved_objects_management/index.ts @@ -12,5 +12,6 @@ export default function savedObjectsManagementApp({ loadTestFile }: FtrProviderC describe('saved objects management', function savedObjectsManagementAppTestSuite() { this.tags('ciGroup7'); loadTestFile(require.resolve('./edit_saved_object')); + loadTestFile(require.resolve('./show_relationships')); }); } diff --git a/test/functional/apps/saved_objects_management/show_relationships.ts b/test/functional/apps/saved_objects_management/show_relationships.ts new file mode 100644 index 0000000000000..6f3fb5a4973e2 --- /dev/null +++ b/test/functional/apps/saved_objects_management/show_relationships.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common', 'settings', 'savedObjects']); + + describe('saved objects relationships flyout', () => { + beforeEach(async () => { + await esArchiver.load('saved_objects_management/show_relationships'); + }); + + afterEach(async () => { + await esArchiver.unload('saved_objects_management/show_relationships'); + }); + + it('displays the invalid references', async () => { + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaSavedObjects(); + + const objects = await PageObjects.savedObjects.getRowTitles(); + expect(objects.includes('Dashboard with missing refs')).to.be(true); + + await PageObjects.savedObjects.clickRelationshipsByTitle('Dashboard with missing refs'); + + const invalidRelations = await PageObjects.savedObjects.getInvalidRelations(); + + expect(invalidRelations).to.eql([ + { + error: 'Saved object [visualization/missing-vis-ref] not found', + id: 'missing-vis-ref', + relationship: 'Child', + type: 'visualization', + }, + { + error: 'Saved object [dashboard/missing-dashboard-ref] not found', + id: 'missing-dashboard-ref', + relationship: 'Child', + type: 'dashboard', + }, + ]); + }); + }); +} diff --git a/test/functional/apps/timelion/_expression_typeahead.js b/test/functional/apps/timelion/_expression_typeahead.js index 744f8de15e767..3db5cb48dd38b 100644 --- a/test/functional/apps/timelion/_expression_typeahead.js +++ b/test/functional/apps/timelion/_expression_typeahead.js @@ -75,18 +75,18 @@ export default function ({ getPageObjects }) { await PageObjects.timelion.updateExpression(',split'); await PageObjects.timelion.clickSuggestion(); const suggestions = await PageObjects.timelion.getSuggestionItemsText(); - expect(suggestions.length).to.eql(51); + expect(suggestions.length).not.to.eql(0); expect(suggestions[0].includes('@message.raw')).to.eql(true); - await PageObjects.timelion.clickSuggestion(10, 2000); + await PageObjects.timelion.clickSuggestion(10); }); it('should show field suggestions for metric argument when index pattern set', async () => { await PageObjects.timelion.updateExpression(',metric'); await PageObjects.timelion.clickSuggestion(); await PageObjects.timelion.updateExpression('avg:'); - await PageObjects.timelion.clickSuggestion(0, 2000); + await PageObjects.timelion.clickSuggestion(0); const suggestions = await PageObjects.timelion.getSuggestionItemsText(); - expect(suggestions.length).to.eql(2); + expect(suggestions.length).not.to.eql(0); expect(suggestions[0].includes('avg:bytes')).to.eql(true); }); }); diff --git a/test/functional/apps/visualize/input_control_vis/input_control_range.ts b/test/functional/apps/visualize/input_control_vis/input_control_range.ts index 9b48e78246b37..613b1a162eb63 100644 --- a/test/functional/apps/visualize/input_control_vis/input_control_range.ts +++ b/test/functional/apps/visualize/input_control_vis/input_control_range.ts @@ -12,7 +12,6 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); - const kibanaServer = getService('kibanaServer'); const find = getService('find'); const security = getService('security'); const { visualize, visEditor } = getPageObjects(['visualize', 'visEditor']); @@ -53,7 +52,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await esArchiver.loadIfNeeded('logstash_functional'); await esArchiver.loadIfNeeded('long_window_logstash'); await esArchiver.load('visualize'); - await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash-*' }); await security.testUser.restoreDefaults(); }); }); diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/show_relationships/data.json b/test/functional/fixtures/es_archiver/saved_objects_management/show_relationships/data.json new file mode 100644 index 0000000000000..4d5b969a3c931 --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/show_relationships/data.json @@ -0,0 +1,36 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "dashboard:dash-with-missing-refs", + "source": { + "dashboard": { + "title": "Dashboard with missing refs", + "hits": 0, + "description": "", + "panelsJSON": "[]", + "optionsJSON": "{}", + "version": 1, + "timeRestore": false, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + } + }, + "type": "dashboard", + "references": [ + { + "type": "visualization", + "id": "missing-vis-ref", + "name": "some missing ref" + }, + { + "type": "dashboard", + "id": "missing-dashboard-ref", + "name": "some other missing ref" + } + ], + "updated_at": "2019-01-22T19:32:47.232Z" + } + } +} diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/show_relationships/mappings.json b/test/functional/fixtures/es_archiver/saved_objects_management/show_relationships/mappings.json new file mode 100644 index 0000000000000..d53e6c96e883e --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/show_relationships/mappings.json @@ -0,0 +1,473 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "auto_expand_replicas": "0-1", + "number_of_replicas": "0" + } + }, + "mappings": { + "dynamic": "strict", + "properties": { + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "apm-telemetry": { + "properties": { + "has_any_services": { + "type": "boolean" + }, + "services_per_agent": { + "properties": { + "go": { + "type": "long", + "null_value": 0 + }, + "java": { + "type": "long", + "null_value": 0 + }, + "js-base": { + "type": "long", + "null_value": 0 + }, + "nodejs": { + "type": "long", + "null_value": 0 + }, + "python": { + "type": "long", + "null_value": 0 + }, + "ruby": { + "type": "long", + "null_value": 0 + } + } + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "id": { + "type": "text", + "index": false + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "accessibility:disableAnimations": { + "type": "boolean" + }, + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "telemetry:optIn": { + "type": "boolean" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "map": { + "properties": { + "bounds": { + "type": "geo_shape", + "tree": "quadtree" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "index-pattern": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "space": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "spaceId": { + "type": "keyword" + }, + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + } + } +} diff --git a/test/functional/page_objects/management/saved_objects_page.ts b/test/functional/page_objects/management/saved_objects_page.ts index 1cdf76ad58ef0..cf162f12df9d9 100644 --- a/test/functional/page_objects/management/saved_objects_page.ts +++ b/test/functional/page_objects/management/saved_objects_page.ts @@ -257,6 +257,22 @@ export function SavedObjectsPageProvider({ getService, getPageObjects }: FtrProv }); } + async getInvalidRelations() { + const rows = await testSubjects.findAll('invalidRelationshipsTableRow'); + return mapAsync(rows, async (row) => { + const objectType = await row.findByTestSubject('relationshipsObjectType'); + const objectId = await row.findByTestSubject('relationshipsObjectId'); + const relationship = await row.findByTestSubject('directRelationship'); + const error = await row.findByTestSubject('relationshipsError'); + return { + type: await objectType.getVisibleText(), + id: await objectId.getVisibleText(), + relationship: await relationship.getVisibleText(), + error: await error.getVisibleText(), + }; + }); + } + async getTableSummary() { const table = await testSubjects.find('savedObjectsTable'); const $ = await table.parseDomContent(); diff --git a/test/functional/services/common/browser.ts b/test/functional/services/common/browser.ts index 635fde6dad720..4a7e82d5b42c0 100644 --- a/test/functional/services/common/browser.ts +++ b/test/functional/services/common/browser.ts @@ -289,13 +289,13 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { } const origin = document.querySelector(arguments[0]); - const target = document.querySelector(arguments[1]); const dragStartEvent = createEvent('dragstart'); dispatchEvent(origin, dragStartEvent); setTimeout(() => { const dropEvent = createEvent('drop'); + const target = document.querySelector(arguments[1]); dispatchEvent(target, dropEvent, dragStartEvent.dataTransfer); const dragEndEvent = createEvent('dragend'); dispatchEvent(origin, dragEndEvent, dropEvent.dataTransfer); diff --git a/test/plugin_functional/test_suites/core_plugins/applications.ts b/test/plugin_functional/test_suites/core_plugins/applications.ts index e72d032f63469..52924d8c93280 100644 --- a/test/plugin_functional/test_suites/core_plugins/applications.ts +++ b/test/plugin_functional/test_suites/core_plugins/applications.ts @@ -19,6 +19,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide const find = getService('find'); const retry = getService('retry'); const deployment = getService('deployment'); + const esArchiver = getService('esArchiver'); const loadingScreenNotShown = async () => expect(await testSubjects.exists('kbnLoadingMessage')).to.be(false); @@ -50,6 +51,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide describe('ui applications', function describeIndexTests() { before(async () => { + await esArchiver.emptyKibanaIndex(); await PageObjects.common.navigateToApp('foo'); }); diff --git a/test/plugin_functional/test_suites/data_plugin/index_patterns.ts b/test/plugin_functional/test_suites/data_plugin/index_patterns.ts index ba12e2df16d41..0cd53a5e1b764 100644 --- a/test/plugin_functional/test_suites/data_plugin/index_patterns.ts +++ b/test/plugin_functional/test_suites/data_plugin/index_patterns.ts @@ -12,8 +12,12 @@ import '../../plugins/core_provider_plugin/types'; export default function ({ getService }: PluginFunctionalProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('index patterns', function () { + before(async () => { + await esArchiver.emptyKibanaIndex(); + }); let indexPatternId = ''; it('can create an index pattern', async () => { diff --git a/test/plugin_functional/test_suites/saved_objects_management/import_warnings.ts b/test/plugin_functional/test_suites/saved_objects_management/import_warnings.ts index 71663b19b35cb..b60e4b4a1d8b7 100644 --- a/test/plugin_functional/test_suites/saved_objects_management/import_warnings.ts +++ b/test/plugin_functional/test_suites/saved_objects_management/import_warnings.ts @@ -10,10 +10,15 @@ import path from 'path'; import expect from '@kbn/expect'; import { PluginFunctionalProviderContext } from '../../services'; -export default function ({ getPageObjects }: PluginFunctionalProviderContext) { +export default function ({ getPageObjects, getService }: PluginFunctionalProviderContext) { const PageObjects = getPageObjects(['common', 'settings', 'header', 'savedObjects']); + const esArchiver = getService('esArchiver'); describe('import warnings', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + }); + beforeEach(async () => { await PageObjects.settings.navigateTo(); await PageObjects.settings.clickKibanaSavedObjects(); diff --git a/test/security_functional/insecure_cluster_warning.ts b/test/security_functional/insecure_cluster_warning.ts index 181c4cf2b46b7..2f7656b743a51 100644 --- a/test/security_functional/insecure_cluster_warning.ts +++ b/test/security_functional/insecure_cluster_warning.ts @@ -31,6 +31,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before(async () => { await browser.setLocalStorageItem('insecureClusterWarningVisibility', ''); await esArchiver.unload('hamlet'); + await esArchiver.emptyKibanaIndex(); }); it('should not warn when the cluster contains no user data', async () => { diff --git a/test/tsconfig.json b/test/tsconfig.json index c8e6e69586ca0..4df74f526077e 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -4,7 +4,12 @@ "incremental": false, "types": ["node", "flot"] }, - "include": ["**/*", "../typings/elastic__node_crypto.d.ts", "typings/**/*", "../packages/kbn-test/types/ftr_globals/**/*"], + "include": [ + "**/*", + "../typings/elastic__node_crypto.d.ts", + "typings/**/*", + "../packages/kbn-test/types/ftr_globals/**/*" + ], "exclude": ["plugin_functional/plugins/**/*", "interpreter_functional/plugins/**/*"], "references": [ { "path": "../src/core/tsconfig.json" }, @@ -34,5 +39,6 @@ { "path": "../src/plugins/ui_actions/tsconfig.json" }, { "path": "../src/plugins/url_forwarding/tsconfig.json" }, { "path": "../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../src/plugins/legacy_export/tsconfig.json" } ] } diff --git a/tsconfig.json b/tsconfig.json index 2647ac9a9d75e..ee46e075f2df1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,11 +21,13 @@ "src/plugins/es_ui_shared/**/*", "src/plugins/expressions/**/*", "src/plugins/home/**/*", + "src/plugins/input_control_vis/**/*", "src/plugins/inspector/**/*", "src/plugins/kibana_legacy/**/*", "src/plugins/kibana_react/**/*", "src/plugins/kibana_usage_collection/**/*", "src/plugins/kibana_utils/**/*", + "src/plugins/legacy_export/**/*", "src/plugins/management/**/*", "src/plugins/maps_legacy/**/*", "src/plugins/navigation/**/*", @@ -84,6 +86,7 @@ { "path": "./src/plugins/kibana_react/tsconfig.json" }, { "path": "./src/plugins/kibana_usage_collection/tsconfig.json" }, { "path": "./src/plugins/kibana_utils/tsconfig.json" }, + { "path": "./src/plugins/legacy_export/tsconfig.json" }, { "path": "./src/plugins/management/tsconfig.json" }, { "path": "./src/plugins/maps_legacy/tsconfig.json" }, { "path": "./src/plugins/navigation/tsconfig.json" }, diff --git a/tsconfig.refs.json b/tsconfig.refs.json index fa1b533a3dd38..16c5b6c116998 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -2,12 +2,12 @@ "include": [], "references": [ { "path": "./src/core/tsconfig.json" }, - { "path": "./src/plugins/telemetry_management_section/tsconfig.json" }, { "path": "./src/plugins/advanced_settings/tsconfig.json" }, { "path": "./src/plugins/apm_oss/tsconfig.json" }, { "path": "./src/plugins/bfetch/tsconfig.json" }, { "path": "./src/plugins/charts/tsconfig.json" }, { "path": "./src/plugins/console/tsconfig.json" }, + { "path": "./src/plugins/dashboard/tsconfig.json" }, { "path": "./src/plugins/data/tsconfig.json" }, { "path": "./src/plugins/dev_tools/tsconfig.json" }, { "path": "./src/plugins/discover/tsconfig.json" }, @@ -15,27 +15,27 @@ { "path": "./src/plugins/es_ui_shared/tsconfig.json" }, { "path": "./src/plugins/expressions/tsconfig.json" }, { "path": "./src/plugins/home/tsconfig.json" }, - { "path": "./src/plugins/dashboard/tsconfig.json" }, - { "path": "./src/plugins/dev_tools/tsconfig.json" }, { "path": "./src/plugins/inspector/tsconfig.json" }, { "path": "./src/plugins/kibana_legacy/tsconfig.json" }, { "path": "./src/plugins/kibana_react/tsconfig.json" }, { "path": "./src/plugins/kibana_usage_collection/tsconfig.json" }, { "path": "./src/plugins/kibana_utils/tsconfig.json" }, + { "path": "./src/plugins/legacy_export/tsconfig.json" }, { "path": "./src/plugins/management/tsconfig.json" }, { "path": "./src/plugins/maps_legacy/tsconfig.json" }, { "path": "./src/plugins/navigation/tsconfig.json" }, { "path": "./src/plugins/newsfeed/tsconfig.json" }, + { "path": "./src/plugins/presentation_util/tsconfig.json" }, { "path": "./src/plugins/region_map/tsconfig.json" }, - { "path": "./src/plugins/saved_objects/tsconfig.json" }, { "path": "./src/plugins/saved_objects_management/tsconfig.json" }, { "path": "./src/plugins/saved_objects_tagging_oss/tsconfig.json" }, - { "path": "./src/plugins/presentation_util/tsconfig.json" }, + { "path": "./src/plugins/saved_objects/tsconfig.json" }, { "path": "./src/plugins/security_oss/tsconfig.json" }, { "path": "./src/plugins/share/tsconfig.json" }, { "path": "./src/plugins/spaces_oss/tsconfig.json" }, - { "path": "./src/plugins/telemetry/tsconfig.json" }, { "path": "./src/plugins/telemetry_collection_manager/tsconfig.json" }, + { "path": "./src/plugins/telemetry_management_section/tsconfig.json" }, + { "path": "./src/plugins/telemetry/tsconfig.json" }, { "path": "./src/plugins/tile_map/tsconfig.json" }, { "path": "./src/plugins/timelion/tsconfig.json" }, { "path": "./src/plugins/ui_actions/tsconfig.json" }, @@ -52,6 +52,6 @@ { "path": "./src/plugins/vis_type_vega/tsconfig.json" }, { "path": "./src/plugins/vis_type_xy/tsconfig.json" }, { "path": "./src/plugins/visualizations/tsconfig.json" }, - { "path": "./src/plugins/visualize/tsconfig.json" }, + { "path": "./src/plugins/visualize/tsconfig.json" } ] } diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index 93cb7a719bbe8..3032d88c26d98 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -130,6 +130,8 @@ def functionalTestProcess(String name, String script) { def ossCiGroupProcess(ciGroup) { return functionalTestProcess("ciGroup" + ciGroup) { + sleep((ciGroup-1)*30) // smooth out CPU spikes from ES startup + withEnv([ "CI_GROUP=${ciGroup}", "JOB=kibana-ciGroup${ciGroup}", @@ -143,6 +145,7 @@ def ossCiGroupProcess(ciGroup) { def xpackCiGroupProcess(ciGroup) { return functionalTestProcess("xpack-ciGroup" + ciGroup) { + sleep((ciGroup-1)*30) // smooth out CPU spikes from ES startup withEnv([ "CI_GROUP=${ciGroup}", "JOB=xpack-kibana-ciGroup${ciGroup}", @@ -444,16 +447,31 @@ def withTasks(Map params = [worker: [:]], Closure closure) { } def allCiTasks() { - withTasks { - tasks.check() - tasks.lint() - tasks.test() - tasks.functionalOss() - tasks.functionalXpack() - } + parallel([ + general: { + withTasks { + tasks.check() + tasks.lint() + tasks.test() + tasks.functionalOss() + tasks.functionalXpack() + } + }, + jest: { + workers.ci(name: 'jest', size: 'c2-8', ramDisk: true) { + scriptTask('Jest Unit Tests', 'test/scripts/test/jest_unit.sh')() + } + }, + xpackJest: { + workers.ci(name: 'xpack-jest', size: 'c2-8', ramDisk: true) { + scriptTask('X-Pack Jest Unit Tests', 'test/scripts/test/xpack_jest_unit.sh')() + } + }, + ]) } def pipelineLibraryTests() { + return whenChanged(['vars/', '.ci/pipeline-library/']) { workers.base(size: 'flyweight', bootstrapped: false, ramDisk: false) { dir('.ci/pipeline-library') { diff --git a/vars/tasks.groovy b/vars/tasks.groovy index 3493a95f0bdce..6c4f897691136 100644 --- a/vars/tasks.groovy +++ b/vars/tasks.groovy @@ -30,12 +30,9 @@ def lint() { def test() { tasks([ - // These 2 tasks require isolation because of hard-coded, conflicting ports and such, so let's use Docker here + // This task requires isolation because of hard-coded, conflicting ports and such, so let's use Docker here kibanaPipeline.scriptTaskDocker('Jest Integration Tests', 'test/scripts/test/jest_integration.sh'), - - kibanaPipeline.scriptTask('Jest Unit Tests', 'test/scripts/test/jest_unit.sh'), kibanaPipeline.scriptTask('API Integration Tests', 'test/scripts/test/api_integration.sh'), - kibanaPipeline.scriptTask('X-Pack Jest Unit Tests', 'test/scripts/test/xpack_jest_unit.sh'), ]) } diff --git a/vars/workers.groovy b/vars/workers.groovy index dd634f3c25a32..e1684f7aadb43 100644 --- a/vars/workers.groovy +++ b/vars/workers.groovy @@ -19,6 +19,8 @@ def label(size) { return 'docker && tests-xl-highmem' case 'xxl': return 'docker && tests-xxl && gobld/machineType:custom-64-270336' + case 'c2-8': + return 'docker && linux && immutable && gobld/machineType:c2-standard-8' } error "unknown size '${size}'" diff --git a/x-pack/build_chromium/README.md b/x-pack/build_chromium/README.md index 9934d06a9d96a..39382620775ad 100644 --- a/x-pack/build_chromium/README.md +++ b/x-pack/build_chromium/README.md @@ -6,50 +6,65 @@ to accept a commit hash from the Chromium repository, and initialize the build environments and run the build on Mac, Windows, and Linux. ## Before you begin + If you wish to use a remote VM to build, you'll need access to our GCP account, which is where we have two machines provisioned for the Linux and Windows builds. Mac builds can be achieved locally, and are a great place to start to gain familiarity. +**NOTE:** Linux builds should be done in Ubuntu on x86 architecture. ARM builds +are created in x86. CentOS is not supported for building Chromium. + 1. Login to our GCP instance [here using your okta credentials](https://console.cloud.google.com/). 2. Click the "Compute Engine" tab. -3. Ensure that `chromium-build-linux` and `chromium-build-windows-12-beefy` are there. -4. If #3 fails, you'll have to spin up new instances. Generally, these need `n1-standard-8` types or 8 vCPUs/30 GB memory. -5. Ensure that there's enough room left on the disk: 100GB is required. `ncdu` is a good linux util to verify what's claming space. - -## Usage +3. Find `chromium-build-linux` or `chromium-build-windows-12-beefy` and start the instance. +4. Install [Google Cloud SDK](https://cloud.google.com/sdk) locally to ssh into the GCP instance +5. System dependencies: + - 8 CPU + - 30GB memory + - 80GB free space on disk (Try `ncdu /home` to see where space is used.) + - git + - python2 (`python` must link to `python2`) + - lsb_release + - tmux is recommended in case your ssh session is interrupted +6. Copy the entire `build_chromium` directory into a GCP storage bucket, so you can copy the scripts into the instance and run them. + +## Build Script Usage ``` +# Allow our scripts to use depot_tools commands export PATH=$HOME/chromium/depot_tools:$PATH + # Create a dedicated working directory for this directory of Python scripts. mkdir ~/chromium && cd ~/chromium + # Copy the scripts from the Kibana repo to use them conveniently in the working directory -cp -r ~/path/to/kibana/x-pack/build_chromium . -# Install the OS packages, configure the environment, download the chromium source +gsutil cp -r gs://my-bucket/build_chromium . + +# Install the OS packages, configure the environment, download the chromium source (25GB) python ./build_chromium/init.sh [arch_name] # Run the build script with the path to the chromium src directory, the git commit id -python ./build_chromium/build.py +python ./build_chromium/build.py x86 -# You can add an architecture flag for ARM +# OR You can build for ARM python ./build_chromium/build.py arm64 ``` +**NOTE:** The `init.py` script updates git config to make it more possible for +the Chromium repo to be cloned successfully. If checking out the Chromium fails +with "early EOF" errors, the instance could be low on memory or disk space. + ## Getting the Commit ID -Getting `` can be tricky. The best technique seems to be: +The `build.py` script requires a commit ID of the Chromium repo. Getting `` can be tricky. The best technique seems to be: 1. Create a temporary working directory and intialize yarn 2. `yarn add puppeteer # install latest puppeter` -3. Look through puppeteer's node module files to find the "chromium revision" (a custom versioning convention for Chromium). +3. Look through Puppeteer documentation and Changelogs to find information +about where the "chromium revision" is located in the Puppeteer code. The code +containing it might not be distributed in the node module. + - Example: https://github.com/puppeteer/puppeteer/blob/b549256/src/revisions.ts 4. Use `https://crrev.com` and look up the revision and find the git commit info. - -The official Chromium build process is poorly documented, and seems to have -breaking changes fairly regularly. The build pre-requisites, and the build -flags change over time, so it is likely that the scripts in this directory will -be out of date by the time we have to do another Chromium build. - -This document is an attempt to note all of the gotchas we've come across while -building, so that the next time we have to tinker here, we'll have a good -starting point. + - Example: http://crrev.com/818858 leads to the git commit e62cb7e3fc7c40548cef66cdf19d270535d9350b ## Build args @@ -115,8 +130,8 @@ The more cores the better, as the build makes effective use of each. For Linux, - Linux: - SSH in using [gcloud](https://cloud.google.com/sdk/) - - Get the ssh command in the [GCP console](https://console.cloud.google.com/) -> VM instances -> your-vm-name -> SSH -> gcloud - - Their in-browser UI is kinda sluggish, so use the commandline tool + - Get the ssh command in the [GCP console](https://console.cloud.google.com/) -> VM instances -> your-vm-name -> SSH -> "View gcloud command" + - Their in-browser UI is kinda sluggish, so use the commandline tool (Google Cloud SDK is required) - Windows: - Install Microsoft's Remote Desktop tools diff --git a/x-pack/build_chromium/build.py b/x-pack/build_chromium/build.py index 8622f4a9d4c0b..0064f48ae973f 100644 --- a/x-pack/build_chromium/build.py +++ b/x-pack/build_chromium/build.py @@ -33,10 +33,10 @@ base_version = source_version[:7].strip('.') # Set to "arm" to build for ARM on Linux -arch_name = sys.argv[2] if len(sys.argv) >= 3 else 'x64' +arch_name = sys.argv[2] if len(sys.argv) >= 3 else 'unknown' if arch_name != 'x64' and arch_name != 'arm64': - raise Exception('Unexpected architecture: ' + arch_name) + raise Exception('Unexpected architecture: ' + arch_name + '. `x64` and `arm64` are supported.') print('Building Chromium ' + source_version + ' for ' + arch_name + ' from ' + src_path) print('src path: ' + src_path) diff --git a/x-pack/build_chromium/init.py b/x-pack/build_chromium/init.py index c0dd60f1cfcb0..3a2e28a884b09 100644 --- a/x-pack/build_chromium/init.py +++ b/x-pack/build_chromium/init.py @@ -8,18 +8,19 @@ # call this once the platform-specific initialization has completed. # Set to "arm" to build for ARM on Linux -arch_name = sys.argv[1] if len(sys.argv) >= 2 else 'x64' +arch_name = sys.argv[1] if len(sys.argv) >= 2 else 'undefined' build_path = path.abspath(os.curdir) src_path = path.abspath(path.join(build_path, 'chromium', 'src')) if arch_name != 'x64' and arch_name != 'arm64': - raise Exception('Unexpected architecture: ' + arch_name) + raise Exception('Unexpected architecture: ' + arch_name + '. `x64` and `arm64` are supported.') # Configure git print('Configuring git globals...') runcmd('git config --global core.autocrlf false') runcmd('git config --global core.filemode false') runcmd('git config --global branch.autosetuprebase always') +runcmd('git config --global core.compression 0') # Grab Chromium's custom build tools, if they aren't already installed # (On Windows, they are installed before this Python script is run) @@ -35,13 +36,14 @@ runcmd('git pull origin master') os.chdir(original_dir) -configure_environment(arch_name, build_path, src_path) - # Fetch the Chromium source code chromium_dir = path.join(build_path, 'chromium') if not path.isdir(chromium_dir): mkdir(chromium_dir) os.chdir(chromium_dir) - runcmd('fetch chromium') + runcmd('fetch chromium --nohooks=1 --no-history=1') else: print('Directory exists: ' + chromium_dir + '. Skipping chromium fetch.') + +# This depends on having the chromium/src directory with the complete checkout +configure_environment(arch_name, build_path, src_path) diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx index ae22718af8b57..43f566a93a89d 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx @@ -107,7 +107,6 @@ export function CustomLinkMenuSection({ - {i18n.translate( 'xpack.apm.transactionActionMenu.customLink.subtitle', diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.test.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.test.tsx index 48c863b460482..3141dc7a5f3c6 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.test.tsx @@ -52,7 +52,7 @@ const renderTransaction = async (transaction: Record) => { } ); - fireEvent.click(rendered.getByText('Actions')); + fireEvent.click(rendered.getByText('Investigate')); return rendered; }; @@ -289,7 +289,7 @@ describe('TransactionActionMenu component', () => { }); const component = renderTransactionActionMenuWithLicense(license); act(() => { - fireEvent.click(component.getByText('Actions')); + fireEvent.click(component.getByText('Investigate')); }); expectTextsNotInDocument(component, ['Custom Links']); }); @@ -313,7 +313,7 @@ describe('TransactionActionMenu component', () => { { wrapper: Wrapper } ); act(() => { - fireEvent.click(component.getByText('Actions')); + fireEvent.click(component.getByText('Investigate')); }); expectTextsNotInDocument(component, ['Custom Links']); }); @@ -330,7 +330,7 @@ describe('TransactionActionMenu component', () => { }); const component = renderTransactionActionMenuWithLicense(license); act(() => { - fireEvent.click(component.getByText('Actions')); + fireEvent.click(component.getByText('Investigate')); }); expectTextsInDocument(component, ['Custom Links']); }); @@ -347,7 +347,7 @@ describe('TransactionActionMenu component', () => { }); const component = renderTransactionActionMenuWithLicense(license); act(() => { - fireEvent.click(component.getByText('Actions')); + fireEvent.click(component.getByText('Investigate')); }); expectTextsInDocument(component, ['Custom Links']); }); @@ -364,7 +364,7 @@ describe('TransactionActionMenu component', () => { }); const component = renderTransactionActionMenuWithLicense(license); act(() => { - fireEvent.click(component.getByText('Actions')); + fireEvent.click(component.getByText('Investigate')); }); expectTextsInDocument(component, ['Custom Links']); act(() => { diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx index 312513db80886..22fa25f93b212 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonEmpty } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; import { useLocation } from 'react-router-dom'; @@ -30,11 +30,11 @@ interface Props { function ActionMenuButton({ onClick }: { onClick: () => void }) { return ( - + {i18n.translate('xpack.apm.transactionActionMenu.actionsButtonLabel', { - defaultMessage: 'Actions', + defaultMessage: 'Investigate', })} - + ); } diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__snapshots__/TransactionActionMenu.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__snapshots__/TransactionActionMenu.test.tsx.snap index fa6db645d28a8..ea33fb3c3df08 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__snapshots__/TransactionActionMenu.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__snapshots__/TransactionActionMenu.test.tsx.snap @@ -10,20 +10,20 @@ exports[`TransactionActionMenu component matches the snapshot 1`] = ` class="euiPopover__anchor" > diff --git a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts index fae43ef148cfa..f6ddb15cbffa9 100644 --- a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts @@ -44,9 +44,7 @@ export async function getTransactionErrorRateChartPreview({ }, }; - const outcomes = getOutcomeAggregation({ - searchAggregatedTransactions: false, - }); + const outcomes = getOutcomeAggregation(); const { intervalString } = getBucketSize({ start, end, numBuckets: 20 }); diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts index 64d9ebb192eb3..9ecf201ede1b7 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEmpty, omit } from 'lodash'; +import { isEmpty, omit, merge } from 'lodash'; import { EventOutcome } from '../../../../common/event_outcome'; import { processSignificantTermAggs, @@ -134,8 +134,7 @@ export async function getErrorRateTimeSeries({ extended_bounds: { min: start, max: end }, }, aggs: { - // TODO: add support for metrics - outcomes: getOutcomeAggregation({ searchAggregatedTransactions: false }), + outcomes: getOutcomeAggregation(), }, }; @@ -147,13 +146,12 @@ export async function getErrorRateTimeSeries({ }; return acc; }, - {} as Record< - string, - { + {} as { + [key: string]: { filter: AggregationOptionsByType['filter']; aggs: { timeseries: typeof timeseriesAgg }; - } - > + }; + } ); const params = { @@ -162,32 +160,25 @@ export async function getErrorRateTimeSeries({ body: { size: 0, query: { bool: { filter: backgroundFilters } }, - aggs: { - // overall aggs - timeseries: timeseriesAgg, - - // per term aggs - ...perTermAggs, - }, + aggs: merge({ timeseries: timeseriesAgg }, perTermAggs), }, }; const response = await apmEventClient.search(params); - type Agg = NonNullable; + const { aggregations } = response; - if (!response.aggregations) { + if (!aggregations) { return {}; } return { overall: { timeseries: getTransactionErrorRateTimeSeries( - response.aggregations.timeseries.buckets + aggregations.timeseries.buckets ), }, significantTerms: topSigTerms.map((topSig, index) => { - // @ts-expect-error - const agg = response.aggregations[`term_${index}`] as Agg; + const agg = aggregations[`term_${index}`]!; return { ...topSig, diff --git a/x-pack/plugins/apm/server/lib/helpers/transaction_error_rate.ts b/x-pack/plugins/apm/server/lib/helpers/transaction_error_rate.ts index 876fc6b822213..2d041006e0e27 100644 --- a/x-pack/plugins/apm/server/lib/helpers/transaction_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/helpers/transaction_error_rate.ts @@ -10,40 +10,21 @@ import { AggregationOptionsByType, AggregationResultOf, } from '../../../../../typings/elasticsearch/aggregations'; -import { getTransactionDurationFieldForAggregatedTransactions } from './aggregated_transactions'; -export function getOutcomeAggregation({ - searchAggregatedTransactions, -}: { - searchAggregatedTransactions: boolean; -}) { - return { - terms: { - field: EVENT_OUTCOME, - include: [EventOutcome.failure, EventOutcome.success], - }, - aggs: { - // simply using the doc count to get the number of requests is not possible for transaction metrics (histograms) - // to work around this we get the number of transactions by counting the number of latency values - count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - }, - }; -} +export const getOutcomeAggregation = () => ({ + terms: { + field: EVENT_OUTCOME, + include: [EventOutcome.failure, EventOutcome.success], + }, +}); + +type OutcomeAggregation = ReturnType; export function calculateTransactionErrorPercentage( - outcomeResponse: AggregationResultOf< - ReturnType, - {} - > + outcomeResponse: AggregationResultOf ) { const outcomes = Object.fromEntries( - outcomeResponse.buckets.map(({ key, count }) => [key, count.value]) + outcomeResponse.buckets.map(({ key, doc_count: count }) => [key, count]) ); const failedTransactions = outcomes[EventOutcome.failure] ?? 0; @@ -56,7 +37,7 @@ export function getTransactionErrorRateTimeSeries( buckets: AggregationResultOf< { date_histogram: AggregationOptionsByType['date_histogram']; - aggs: { outcomes: ReturnType }; + aggs: { outcomes: OutcomeAggregation }; }, {} >['buckets'] diff --git a/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts b/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts index 5531944fc7180..fa4bf6144fb6f 100644 --- a/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts +++ b/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts @@ -11,10 +11,7 @@ import { rangeFilter } from '../../../common/utils/range_filter'; import { Coordinates } from '../../../../observability/typings/common'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; -import { - getProcessorEventForAggregatedTransactions, - getTransactionDurationFieldForAggregatedTransactions, -} from '../helpers/aggregated_transactions'; +import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; export async function getTransactionCoordinates({ setup, @@ -49,15 +46,6 @@ export async function getTransactionCoordinates({ fixed_interval: bucketSize, min_doc_count: 0, }, - aggs: { - count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - }, }, }, }, @@ -68,7 +56,7 @@ export async function getTransactionCoordinates({ return ( aggregations?.distribution.buckets.map((bucket) => ({ x: bucket.key, - y: bucket.count.value / deltaAsMinutes, + y: bucket.doc_count / deltaAsMinutes, })) || [] ); } diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts index f7ca40ef1052c..173de796d47e4 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts @@ -52,8 +52,10 @@ describe('getServiceMapServiceNodeInfo', () => { apmEventClient: { search: () => Promise.resolve({ + hits: { + total: { value: 1 }, + }, aggregations: { - count: { value: 1 }, duration: { value: null }, avgCpuUsage: { value: null }, avgMemoryUsage: { value: null }, diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts index 82d339686f7ec..4fe9a1a75d43f 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts @@ -162,19 +162,12 @@ async function getTransactionStats({ ), }, }, - count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, }, }, }; const response = await apmEventClient.search(params); - const totalRequests = response.aggregations?.count.value ?? 0; + const totalRequests = response.hits.total.value; return { avgTransactionDuration: response.aggregations?.duration.value ?? null, diff --git a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap index 21402e4c8dac0..239b909e1572c 100644 --- a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap @@ -122,13 +122,6 @@ Array [ }, }, "outcomes": Object { - "aggs": Object { - "count": Object { - "value_count": Object { - "field": "transaction.duration.us", - }, - }, - }, "terms": Object { "field": "event.outcome", "include": Array [ @@ -137,11 +130,6 @@ Array [ ], }, }, - "real_document_count": Object { - "value_count": Object { - "field": "transaction.duration.us", - }, - }, "timeseries": Object { "aggs": Object { "avg_duration": Object { @@ -150,13 +138,6 @@ Array [ }, }, "outcomes": Object { - "aggs": Object { - "count": Object { - "value_count": Object { - "field": "transaction.duration.us", - }, - }, - }, "terms": Object { "field": "event.outcome", "include": Array [ @@ -165,11 +146,6 @@ Array [ ], }, }, - "real_document_count": Object { - "value_count": Object { - "field": "transaction.duration.us", - }, - }, }, "date_histogram": Object { "extended_bounds": Object { @@ -184,9 +160,6 @@ Array [ }, "terms": Object { "field": "transaction.type", - "order": Object { - "real_document_count": "desc", - }, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts index 5880b5cbc9546..c5e5269c4409e 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts @@ -30,18 +30,17 @@ export async function getServiceInstanceTransactionStats({ }: ServiceInstanceParams) { const { apmEventClient, start, end, esFilter } = setup; - const { intervalString } = getBucketSize({ start, end, numBuckets }); + const { intervalString, bucketSize } = getBucketSize({ + start, + end, + numBuckets, + }); const field = getTransactionDurationFieldForAggregatedTransactions( searchAggregatedTransactions ); const subAggs = { - count: { - value_count: { - field, - }, - }, avg_transaction_duration: { avg: { field, @@ -53,13 +52,6 @@ export async function getServiceInstanceTransactionStats({ [EVENT_OUTCOME]: EventOutcome.failure, }, }, - aggs: { - count: { - value_count: { - field, - }, - }, - }, }, }; @@ -113,12 +105,13 @@ export async function getServiceInstanceTransactionStats({ }); const deltaAsMinutes = (end - start) / 60 / 1000; + const bucketSizeInMinutes = bucketSize / 60; return ( response.aggregations?.[SERVICE_NODE_NAME].buckets.map( (serviceNodeBucket) => { const { - count, + doc_count: count, avg_transaction_duration: avgTransactionDuration, key, failures, @@ -128,17 +121,17 @@ export async function getServiceInstanceTransactionStats({ return { serviceNodeName: String(key), errorRate: { - value: failures.count.value / count.value, + value: failures.doc_count / count, timeseries: timeseries.buckets.map((dateBucket) => ({ x: dateBucket.key, - y: dateBucket.failures.count.value / dateBucket.count.value, + y: dateBucket.failures.doc_count / dateBucket.doc_count, })), }, throughput: { - value: count.value / deltaAsMinutes, + value: count / deltaAsMinutes, timeseries: timeseries.buckets.map((dateBucket) => ({ x: dateBucket.key, - y: dateBucket.count.value / deltaAsMinutes, + y: dateBucket.doc_count / bucketSizeInMinutes, })), }, latency: { diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts index 937155bc31602..745535f261673 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts @@ -17,6 +17,7 @@ import { import { ESFilter } from '../../../../../../typings/elasticsearch'; import { + getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, getTransactionDurationFieldForAggregatedTransactions, } from '../../helpers/aggregated_transactions'; @@ -76,6 +77,9 @@ export async function getTimeseriesDataForTransactionGroups({ { term: { [SERVICE_NAME]: serviceName } }, { term: { [TRANSACTION_TYPE]: transactionType } }, { range: rangeFilter(start, end) }, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), ...esFilter, ], }, @@ -99,10 +103,8 @@ export async function getTimeseriesDataForTransactionGroups({ }, aggs: { ...getLatencyAggregation(latencyAggregationType, field), - transaction_count: { value_count: { field } }, [EVENT_OUTCOME]: { filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, - aggs: { transaction_count: { value_count: { field } } }, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts index ccccf946512dd..400c896e380b4 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts @@ -99,10 +99,8 @@ export async function getTransactionGroupsForPage({ }, aggs: { ...getLatencyAggregation(latencyAggregationType, field), - transaction_count: { value_count: { field } }, [EVENT_OUTCOME]: { filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, - aggs: { transaction_count: { value_count: { field } } }, }, }, }, @@ -113,9 +111,8 @@ export async function getTransactionGroupsForPage({ const transactionGroups = response.aggregations?.transaction_groups.buckets.map((bucket) => { const errorRate = - bucket.transaction_count.value > 0 - ? (bucket[EVENT_OUTCOME].transaction_count.value ?? 0) / - bucket.transaction_count.value + bucket.doc_count > 0 + ? bucket[EVENT_OUTCOME].doc_count / bucket.doc_count : null; return { @@ -124,7 +121,7 @@ export async function getTransactionGroupsForPage({ latencyAggregationType, aggregation: bucket.latency, }), - throughput: bucket.transaction_count.value / deltaAsMinutes, + throughput: bucket.doc_count / deltaAsMinutes, errorRate, }; }) ?? []; diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts index a8794e3c09a40..b0b1cb09dd784 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts @@ -52,18 +52,14 @@ export function mergeTransactionGroupData({ ...acc.throughput, timeseries: acc.throughput.timeseries.concat({ x: point.key, - y: point.transaction_count.value / deltaAsMinutes, + y: point.doc_count / deltaAsMinutes, }), }, errorRate: { ...acc.errorRate, timeseries: acc.errorRate.timeseries.concat({ x: point.key, - y: - point.transaction_count.value > 0 - ? (point[EVENT_OUTCOME].transaction_count.value ?? 0) / - point.transaction_count.value - : null, + y: point[EVENT_OUTCOME].doc_count / point.doc_count, }), }, }; diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts index 0ee7080dc0834..efbc30169d178 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts @@ -51,16 +51,9 @@ export async function getServiceTransactionStats({ }: AggregationParams) { const { apmEventClient, start, end, esFilter } = setup; - const outcomes = getOutcomeAggregation({ searchAggregatedTransactions }); + const outcomes = getOutcomeAggregation(); const metrics = { - real_document_count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, avg_duration: { avg: { field: getTransactionDurationFieldForAggregatedTransactions( @@ -102,7 +95,6 @@ export async function getServiceTransactionStats({ transactionType: { terms: { field: TRANSACTION_TYPE, - order: { real_document_count: 'desc' }, }, aggs: { ...metrics, @@ -180,14 +172,14 @@ export async function getServiceTransactionStats({ }, transactionsPerMinute: { value: calculateAvgDuration({ - value: topTransactionTypeBucket.real_document_count.value, + value: topTransactionTypeBucket.doc_count, deltaAsMinutes, }), timeseries: topTransactionTypeBucket.timeseries.buckets.map( (dateBucket) => ({ x: dateBucket.key, y: calculateAvgDuration({ - value: dateBucket.real_document_count.value, + value: dateBucket.doc_count, deltaAsMinutes, }), }) diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap index c678e7db711b6..89069d74bacf8 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap @@ -12,11 +12,6 @@ Array [ "aggs": Object { "transaction_groups": Object { "aggs": Object { - "count": Object { - "value_count": Object { - "field": "transaction.duration.us", - }, - }, "transaction_type": Object { "top_hits": Object { "_source": Array [ @@ -226,11 +221,6 @@ Array [ "aggs": Object { "transaction_groups": Object { "aggs": Object { - "count": Object { - "value_count": Object { - "field": "transaction.duration.us", - }, - }, "transaction_type": Object { "top_hits": Object { "_source": Array [ diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts index dfd11203b87f1..a2388dddc7fd4 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts @@ -14,7 +14,10 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { EventOutcome } from '../../../common/event_outcome'; import { rangeFilter } from '../../../common/utils/range_filter'; -import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; +import { + getDocumentTypeFilterForAggregatedTransactions, + getProcessorEventForAggregatedTransactions, +} from '../helpers/aggregated_transactions'; import { getBucketSize } from '../helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { @@ -55,12 +58,15 @@ export async function getErrorRate({ { terms: { [EVENT_OUTCOME]: [EventOutcome.failure, EventOutcome.success] }, }, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), ...transactionNamefilter, ...transactionTypefilter, ...esFilter, ]; - const outcomes = getOutcomeAggregation({ searchAggregatedTransactions }); + const outcomes = getOutcomeAggregation(); const params = { apm: { diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts index cfd3540446172..dba58cecad79b 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts @@ -66,13 +66,6 @@ export async function getCounts({ searchAggregatedTransactions, }: MetricParams) { const params = mergeRequestWithAggs(request, { - count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, transaction_type: { top_hits: { size: 1, @@ -92,7 +85,7 @@ export async function getCounts({ return { key: bucket.key as BucketKey, - count: bucket.count.value, + count: bucket.doc_count, transactionType: source.transaction.type, }; }); diff --git a/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts index be374ccfe3400..8dfb0a9f65878 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts @@ -15,7 +15,6 @@ import { rangeFilter } from '../../../../common/utils/range_filter'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, - getTransactionDurationFieldForAggregatedTransactions, } from '../../../lib/helpers/aggregated_transactions'; import { getBucketSize } from '../../../lib/helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../../lib/helpers/setup_request'; @@ -56,10 +55,6 @@ async function searchThroughput({ filter.push({ term: { [TRANSACTION_NAME]: transactionName } }); } - const field = getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ); - const params = { apm: { events: [ @@ -82,7 +77,6 @@ async function searchThroughput({ min_doc_count: 0, extended_bounds: { min: start, max: end }, }, - aggs: { count: { value_count: { field } } }, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/transform.ts b/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/transform.ts index a12e36c0e9de4..7e43a0d76f70a 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/transform.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/transform.ts @@ -25,7 +25,7 @@ export function getThroughputBuckets({ return { x: bucket.key, // divide by minutes - y: bucket.count.value / (bucketSize / 60), + y: bucket.doc_count / (bucketSize / 60), }; }); @@ -34,7 +34,7 @@ export function getThroughputBuckets({ resultKey === '' ? NOT_AVAILABLE_LABEL : (resultKey as string); const docCountTotal = timeseries.buckets - .map((bucket) => bucket.count.value) + .map((bucket) => bucket.doc_count) .reduce((a, b) => a + b, 0); // calculate average throughput diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/get_demo_rows.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/get_demo_rows.ts index 58a2354b5cf38..ff5a4506ab82a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/get_demo_rows.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/get_demo_rows.ts @@ -5,8 +5,10 @@ */ import { cloneDeep } from 'lodash'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import ci from './ci.json'; import { DemoRows } from './demo_rows_types'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import shirts from './shirts.json'; import { getFunctionErrors } from '../../../../i18n'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/index.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/index.tsx index a9296bd9a1241..238b2edc3bd6d 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/index.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/index.tsx @@ -12,7 +12,7 @@ import { Popover } from '../../../public/components/popover'; import { RendererStrings } from '../../../i18n'; import { RendererFactory } from '../../../types'; -interface Config { +export interface Config { error: Error; } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx index bfc36932a8a07..6c1dd086c8667 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx @@ -15,7 +15,7 @@ import { RendererStrings } from '../../../../i18n'; const { dropdownFilter: strings } = RendererStrings; -interface Config { +export interface Config { /** The column to use within the exactly function */ column: string; /** diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/table.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/table.tsx index ada159e07f6ae..4933b1b4ba51d 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/table.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/table.tsx @@ -12,7 +12,7 @@ import { RendererFactory, Style, Datatable } from '../../types'; const { dropdownFilter: strings } = RendererStrings; -interface TableArguments { +export interface TableArguments { font?: Style; paginate: boolean; perPage: number; diff --git a/x-pack/plugins/canvas/public/apps/export/export/export_app.component.tsx b/x-pack/plugins/canvas/public/apps/export/export/export_app.component.tsx index 03121e749d0dc..f26408b1200f1 100644 --- a/x-pack/plugins/canvas/public/apps/export/export/export_app.component.tsx +++ b/x-pack/plugins/canvas/public/apps/export/export/export_app.component.tsx @@ -13,7 +13,7 @@ import { WorkpadPage } from '../../../components/workpad_page'; import { Link } from '../../../components/link'; import { CanvasWorkpad } from '../../../../types'; -interface Props { +export interface Props { workpad: CanvasWorkpad; selectedPageIndex: number; initializeWorkpad: () => void; diff --git a/x-pack/plugins/canvas/public/apps/home/home_app/home_app.component.tsx b/x-pack/plugins/canvas/public/apps/home/home_app/home_app.component.tsx index 3c2e989cc8e51..7fbdc24c112a1 100644 --- a/x-pack/plugins/canvas/public/apps/home/home_app/home_app.component.tsx +++ b/x-pack/plugins/canvas/public/apps/home/home_app/home_app.component.tsx @@ -11,7 +11,7 @@ import { WorkpadManager } from '../../../components/workpad_manager'; // @ts-expect-error untyped local import { setDocTitle } from '../../../lib/doc_title'; -interface Props { +export interface Props { onLoad: () => void; } diff --git a/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx index 981334ff8d9f2..3697d5dad2dae 100644 --- a/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx +++ b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx @@ -46,7 +46,7 @@ interface ResolvedArgs { [keys: string]: any; } -interface ElementsLoadedTelemetryProps extends PropsFromRedux { +export interface ElementsLoadedTelemetryProps extends PropsFromRedux { workpad: Workpad; } diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset.component.tsx b/x-pack/plugins/canvas/public/components/asset_manager/asset.component.tsx index ed000741bc542..d94802bf2a772 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/asset.component.tsx +++ b/x-pack/plugins/canvas/public/components/asset_manager/asset.component.tsx @@ -28,7 +28,7 @@ import { ComponentStrings } from '../../../i18n'; const { Asset: strings } = ComponentStrings; -interface Props { +export interface Props { /** The asset to be rendered */ asset: AssetType; /** The function to execute when the user clicks 'Create' */ diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx index 98f3d8b48829d..6c1b546b49aa1 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx +++ b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx @@ -33,7 +33,7 @@ import { ComponentStrings } from '../../../i18n'; const { AssetManager: strings } = ComponentStrings; -interface Props { +export interface Props { /** The assets to display within the modal */ assets: AssetType[]; /** Function to invoke when the modal is closed */ diff --git a/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx b/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx index 31a75acbba4ec..9d0a5e0a9f51d 100644 --- a/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx +++ b/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx @@ -8,7 +8,7 @@ import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; import PropTypes from 'prop-types'; import React, { FunctionComponent } from 'react'; -interface Props { +export interface Props { isOpen: boolean; title?: string; message: string; diff --git a/x-pack/plugins/canvas/public/components/page_preview/page_preview.component.tsx b/x-pack/plugins/canvas/public/components/page_preview/page_preview.component.tsx index fd1dc869d60ec..da1fe8473e36d 100644 --- a/x-pack/plugins/canvas/public/components/page_preview/page_preview.component.tsx +++ b/x-pack/plugins/canvas/public/components/page_preview/page_preview.component.tsx @@ -10,7 +10,7 @@ import { DomPreview } from '../dom_preview'; import { PageControls } from './page_controls'; import { CanvasPage } from '../../../types'; -interface Props { +export interface Props { isWriteable: boolean; page: Pick; height: number; diff --git a/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx b/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx index 7151e72a44780..d33ba57050d4b 100644 --- a/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx +++ b/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx @@ -31,7 +31,7 @@ const { Toolbar: strings } = ComponentStrings; type TrayType = 'pageManager' | 'expression'; -interface Props { +export interface Props { isWriteable: boolean; selectedElement?: CanvasElement; selectedPageNumber: number; diff --git a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.component.tsx b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.component.tsx index a7424882f1072..4068272bbaf11 100644 --- a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.component.tsx @@ -30,7 +30,7 @@ import { ComponentStrings } from '../../../i18n'; const { WorkpadConfig: strings } = ComponentStrings; -interface Props { +export interface Props { size: { height: number; width: number; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.component.tsx b/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.component.tsx index d651e649128f9..023d87c7c3565 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.component.tsx @@ -12,7 +12,7 @@ import { ToolTipShortcut } from '../../tool_tip_shortcut'; import { ComponentStrings } from '../../../../i18n'; const { WorkpadHeaderRefreshControlSettings: strings } = ComponentStrings; -interface Props { +export interface Props { doRefresh: MouseEventHandler; inFlight: boolean; } diff --git a/x-pack/plugins/canvas/public/functions/filters.ts b/x-pack/plugins/canvas/public/functions/filters.ts index fdb5d69d35515..70120ccad6f54 100644 --- a/x-pack/plugins/canvas/public/functions/filters.ts +++ b/x-pack/plugins/canvas/public/functions/filters.ts @@ -15,7 +15,7 @@ import { ExpressionValueFilter } from '../../types'; import { getFunctionHelp } from '../../i18n'; import { InitializeArguments } from '.'; -interface Arguments { +export interface Arguments { group: string[]; ungrouped: boolean; } diff --git a/x-pack/plugins/canvas/public/functions/pie.ts b/x-pack/plugins/canvas/public/functions/pie.ts index ab3f1b932dc3c..e7cf153b9cd0f 100644 --- a/x-pack/plugins/canvas/public/functions/pie.ts +++ b/x-pack/plugins/canvas/public/functions/pie.ts @@ -61,7 +61,7 @@ export interface Pie { options: PieOptions; } -interface Arguments { +export interface Arguments { palette: PaletteOutput; seriesStyle: SeriesStyle[]; radius: number | 'auto'; diff --git a/x-pack/plugins/canvas/public/functions/plot/index.ts b/x-pack/plugins/canvas/public/functions/plot/index.ts index a4661dc3401df..79aa11cfa2d80 100644 --- a/x-pack/plugins/canvas/public/functions/plot/index.ts +++ b/x-pack/plugins/canvas/public/functions/plot/index.ts @@ -17,7 +17,7 @@ import { getTickHash } from './get_tick_hash'; import { getFunctionHelp } from '../../../i18n'; import { AxisConfig, PointSeries, Render, SeriesStyle, Legend } from '../../../types'; -interface Arguments { +export interface Arguments { seriesStyle: SeriesStyle[]; defaultStyle: SeriesStyle; palette: PaletteOutput; diff --git a/x-pack/plugins/canvas/public/functions/timelion.ts b/x-pack/plugins/canvas/public/functions/timelion.ts index 947972fa310c9..3018540e5bf8e 100644 --- a/x-pack/plugins/canvas/public/functions/timelion.ts +++ b/x-pack/plugins/canvas/public/functions/timelion.ts @@ -15,7 +15,7 @@ import { Datatable, ExpressionValueFilter } from '../../types'; import { getFunctionHelp } from '../../i18n'; import { InitializeArguments } from './'; -interface Arguments { +export interface Arguments { query: string; interval: string; from: string; diff --git a/x-pack/plugins/canvas/public/functions/to.ts b/x-pack/plugins/canvas/public/functions/to.ts index 36b2d3f9f04c6..c8ac4f714e5c4 100644 --- a/x-pack/plugins/canvas/public/functions/to.ts +++ b/x-pack/plugins/canvas/public/functions/to.ts @@ -10,7 +10,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/public'; import { getFunctionHelp, getFunctionErrors } from '../../i18n'; import { InitializeArguments } from '.'; -interface Arguments { +export interface Arguments { type: string[]; } diff --git a/x-pack/plugins/canvas/public/lib/template_from_react_component.tsx b/x-pack/plugins/canvas/public/lib/template_from_react_component.tsx index f4e715b1bbc49..95225cf13ff3b 100644 --- a/x-pack/plugins/canvas/public/lib/template_from_react_component.tsx +++ b/x-pack/plugins/canvas/public/lib/template_from_react_component.tsx @@ -11,7 +11,7 @@ import { I18nProvider } from '@kbn/i18n/react'; import { ErrorBoundary } from '../components/enhance/error_boundary'; import { ArgumentHandlers } from '../../types/arguments'; -interface Props { +export interface Props { renderError: Function; } diff --git a/x-pack/plugins/canvas/server/sample_data/index.ts b/x-pack/plugins/canvas/server/sample_data/index.ts index 212d9f5132831..9c9ecb718fd5f 100644 --- a/x-pack/plugins/canvas/server/sample_data/index.ts +++ b/x-pack/plugins/canvas/server/sample_data/index.ts @@ -3,9 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import ecommerceSavedObjects from './ecommerce_saved_objects.json'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import flightsSavedObjects from './flights_saved_objects.json'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import webLogsSavedObjects from './web_logs_saved_objects.json'; import { loadSampleData } from './load_sample_data'; diff --git a/x-pack/plugins/canvas/shareable_runtime/context/actions.ts b/x-pack/plugins/canvas/shareable_runtime/context/actions.ts index 8c88afbadfd9e..a36435688505d 100644 --- a/x-pack/plugins/canvas/shareable_runtime/context/actions.ts +++ b/x-pack/plugins/canvas/shareable_runtime/context/actions.ts @@ -17,7 +17,7 @@ export enum CanvasShareableActions { SET_TOOLBAR_AUTOHIDE = 'SET_TOOLBAR_AUTOHIDE', } -interface FluxAction { +export interface FluxAction { type: T; payload: P; } diff --git a/x-pack/plugins/canvas/shareable_runtime/test/index.ts b/x-pack/plugins/canvas/shareable_runtime/test/index.ts index 288dd0dc3a5be..f0d2ebcc20128 100644 --- a/x-pack/plugins/canvas/shareable_runtime/test/index.ts +++ b/x-pack/plugins/canvas/shareable_runtime/test/index.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import hello from './workpads/hello.json'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import austin from './workpads/austin.json'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import test from './workpads/test.json'; export * from './utils'; diff --git a/x-pack/plugins/canvas/tsconfig.json b/x-pack/plugins/canvas/tsconfig.json new file mode 100644 index 0000000000000..3e3986082e207 --- /dev/null +++ b/x-pack/plugins/canvas/tsconfig.json @@ -0,0 +1,52 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "../../../typings/**/*", + "__fixtures__/**/*", + "canvas_plugin_src/**/*", + "common/**/*", + "i18n/**/*", + "public/**/*", + "server/**/*", + "shareable_runtime/**/*", + "storybook/**/*", + "tasks/mocks/*", + "types/**/*", + "**/*.json", + ], + "exclude": [ + // these files are too large and upset tsc, so we exclude them + "server/sample_data/*.json", + "canvas_plugin_src/functions/server/demodata/*.json", + "shareable_runtime/test/workpads/*.json", + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/bfetch/tsconfig.json"}, + { "path": "../../../src/plugins/charts/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json"}, + { "path": "../../../src/plugins/discover/tsconfig.json" }, + { "path": "../../../src/plugins/embeddable/tsconfig.json" }, + { "path": "../../../src/plugins/expressions/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/inspector/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_legacy/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../../src/plugins/saved_objects/tsconfig.json" }, + { "path": "../../../src/plugins/ui_actions/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../../../src/plugins/visualizations/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../lens/tsconfig.json" }, + { "path": "../maps/tsconfig.json" }, + { "path": "../reporting/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/canvas/types/state.ts b/x-pack/plugins/canvas/types/state.ts index 03bb931dc9b26..33f913563daac 100644 --- a/x-pack/plugins/canvas/types/state.ts +++ b/x-pack/plugins/canvas/types/state.ts @@ -52,7 +52,7 @@ type ExpressionType = | Style | Range; -interface ExpressionRenderable { +export interface ExpressionRenderable { state: 'ready' | 'pending'; value: Render | null; error: null; diff --git a/x-pack/plugins/case/common/api/cases/configure.ts b/x-pack/plugins/case/common/api/cases/configure.ts index b82c6de8fc363..398f73f2721a6 100644 --- a/x-pack/plugins/case/common/api/cases/configure.ts +++ b/x-pack/plugins/case/common/api/cases/configure.ts @@ -6,11 +6,12 @@ import * as rt from 'io-ts'; -import { ActionResult } from '../../../../actions/common'; +import { ActionResult, ActionType } from '../../../../actions/common'; import { UserRT } from '../user'; import { CaseConnectorRt, ConnectorMappingsRt, ESCaseConnector } from '../connectors'; export type ActionConnector = ActionResult; +export type ActionTypeConnector = ActionType; // TODO: we will need to add this type rt.literal('close-by-third-party') const ClosureTypeRT = rt.union([rt.literal('close-by-user'), rt.literal('close-by-pushing')]); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts index 40911496d6494..3a12b50cf8f68 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -14,13 +14,15 @@ import { CaseConfigureService, ConnectorMappingsService, } from '../../../services'; -import { getActions } from '../__mocks__/request_responses'; +import { getActions, getActionTypes } from '../__mocks__/request_responses'; import { authenticationMock } from '../__fixtures__'; import type { CasesRequestHandlerContext } from '../../../types'; export const createRouteContext = async (client: any, badAuth = false) => { const actionsMock = actionsClientMock.create(); actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); + actionsMock.listTypes.mockImplementation(() => Promise.resolve(getActionTypes())); + const log = loggingSystemMock.create().get('case'); const esClientMock = elasticsearchServiceMock.createClusterClient(); diff --git a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts index efc3b6044a804..236deb9c7462c 100644 --- a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts +++ b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { + ActionTypeConnector, CasePostRequest, CasesConfigureRequest, ConnectorTypes, @@ -73,6 +74,49 @@ export const getActions = (): FindActionResult[] => [ }, ]; +export const getActionTypes = (): ActionTypeConnector[] => [ + { + id: '.email', + name: 'Email', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.index', + name: 'Index', + minimumLicenseRequired: 'basic', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.servicenow', + name: 'ServiceNow', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.resilient', + name: 'IBM Resilient', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, +]; + export const newConfiguration: CasesConfigureRequest = { connector: { id: '456', diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts index b744a6dc04810..974ae9283dd98 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts @@ -42,10 +42,72 @@ describe('GET connectors', () => { expect(res.status).toEqual(200); const expected = getActions(); + // The first connector returned by getActions is of type .webhook and we expect to be filtered expected.shift(); expect(res.payload).toEqual(expected); }); + it('filters out connectors that are not enabled in license', async () => { + const req = httpServerMock.createKibanaRequest({ + path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`, + method: 'get', + }); + + const context = await createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, + }) + ); + + const actionsClient = context.actions.getActionsClient(); + (actionsClient.listTypes as jest.Mock).mockImplementation(() => + Promise.resolve([ + { + id: '.servicenow', + name: 'ServiceNow', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + // User does not have a platinum license + enabledInLicense: false, + }, + { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.resilient', + name: 'IBM Resilient', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + // User does not have a platinum license + enabledInLicense: false, + }, + ]) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(200); + expect(res.payload).toEqual([ + { + id: '456', + actionTypeId: '.jira', + name: 'Connector without isCaseOwned', + config: { + apiUrl: 'https://elastic.jira.com', + }, + isPreconfigured: false, + referencedByCount: 0, + }, + ]); + }); + it('it throws an error when actions client is null', async () => { const req = httpServerMock.createKibanaRequest({ path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`, diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts index cb88f04a9b835..cf854df9f04f2 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts @@ -7,6 +7,7 @@ import Boom from '@hapi/boom'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; +import { ActionType } from '../../../../../../actions/common'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FindActionResult } from '../../../../../../actions/server/types'; @@ -17,10 +18,13 @@ import { RESILIENT_ACTION_TYPE_ID, } from '../../../../../common/constants'; -const isConnectorSupported = (action: FindActionResult): boolean => +const isConnectorSupported = ( + action: FindActionResult, + actionTypes: Record +): boolean => [SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID].includes( action.actionTypeId - ); + ) && actionTypes[action.actionTypeId]?.enabledInLicense; /* * Be aware that this api will only return 20 connectors @@ -40,7 +44,14 @@ export function initCaseConfigureGetActionConnector({ router }: RouteDeps) { throw Boom.notFound('Action client have not been found'); } - const results = (await actionsClient.getAll()).filter(isConnectorSupported); + const actionTypes = (await actionsClient.listTypes()).reduce( + (types, type) => ({ ...types, [type.id]: type }), + {} + ); + + const results = (await actionsClient.getAll()).filter((action) => + isConnectorSupported(action, actionTypes) + ); return response.ok({ body: results }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/data_enhanced/common/search/test_data/search_phase_execution_exception.json b/x-pack/plugins/data_enhanced/common/search/test_data/search_phase_execution_exception.json new file mode 100644 index 0000000000000..b79a396445e3d --- /dev/null +++ b/x-pack/plugins/data_enhanced/common/search/test_data/search_phase_execution_exception.json @@ -0,0 +1,229 @@ +{ + "error": { + "root_cause": [ + { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + } + }, + { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + } + }, + { + "type": "parse_exception", + "reason": "failed to parse date field [2021-01-19T12:2755.047Z] with format [strict_date_optional_time]: [failed to parse date field [2021-01-19T12:2755.047Z] with format [strict_date_optional_time]]" + }, + { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + } + }, + { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + } + }, + { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + } + } + ], + "type": "search_phase_execution_exception", + "reason": "all shards failed", + "phase": "query", + "grouped": true, + "failed_shards": [ + { + "shard": 0, + "index": ".apm-agent-configuration", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + }, + "caused_by": { + "type": "illegal_argument_exception", + "reason": "cannot resolve symbol [invalid]" + } + } + }, + { + "shard": 0, + "index": ".apm-custom-link", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + }, + "caused_by": { + "type": "illegal_argument_exception", + "reason": "cannot resolve symbol [invalid]" + } + } + }, + { + "shard": 0, + "index": ".kibana-event-log-8.0.0-000001", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "parse_exception", + "reason": "failed to parse date field [2021-01-19T12:2755.047Z] with format [strict_date_optional_time]: [failed to parse date field [2021-01-19T12:2755.047Z] with format [strict_date_optional_time]]", + "caused_by": { + "type": "illegal_argument_exception", + "reason": "failed to parse date field [2021-01-19T12:2755.047Z] with format [strict_date_optional_time]", + "caused_by": { + "type": "date_time_parse_exception", + "reason": "Text '2021-01-19T12:2755.047Z' could not be parsed, unparsed text found at index 16" + } + } + } + }, + { + "shard": 0, + "index": ".kibana_1", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + }, + "caused_by": { + "type": "illegal_argument_exception", + "reason": "cannot resolve symbol [invalid]" + } + } + }, + { + "shard": 0, + "index": ".kibana_task_manager_1", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + }, + "caused_by": { + "type": "illegal_argument_exception", + "reason": "cannot resolve symbol [invalid]" + } + } + }, + { + "shard": 0, + "index": ".security-7", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + }, + "caused_by": { + "type": "illegal_argument_exception", + "reason": "cannot resolve symbol [invalid]" + } + } + } + ] + }, + "status": 400 +} \ No newline at end of file diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts index 1a6fc724e2cf2..22b0f3272ff7d 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts @@ -9,10 +9,16 @@ import { EnhancedSearchInterceptor } from './search_interceptor'; import { CoreSetup, CoreStart } from 'kibana/public'; import { UI_SETTINGS } from '../../../../../src/plugins/data/common'; import { AbortError } from '../../../../../src/plugins/kibana_utils/public'; -import { ISessionService, SearchTimeoutError, SearchSessionState } from 'src/plugins/data/public'; +import { + ISessionService, + SearchTimeoutError, + SearchSessionState, + PainlessError, +} from 'src/plugins/data/public'; import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { bfetchPluginMock } from '../../../../../src/plugins/bfetch/public/mocks'; import { BehaviorSubject } from 'rxjs'; +import * as xpackResourceNotFoundException from '../../common/search/test_data/search_phase_execution_exception.json'; const timeTravel = (msToRun = 0) => { jest.advanceTimersByTime(msToRun); @@ -99,6 +105,33 @@ describe('EnhancedSearchInterceptor', () => { }); }); + describe('errors', () => { + test('Should throw Painless error on server error with OSS format', async () => { + const mockResponse: any = { + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: xpackResourceNotFoundException.error, + }; + fetchMock.mockRejectedValueOnce(mockResponse); + const response = searchInterceptor.search({ + params: {}, + }); + await expect(response.toPromise()).rejects.toThrow(PainlessError); + }); + + test('Renders a PainlessError', async () => { + searchInterceptor.showError( + new PainlessError({ + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: xpackResourceNotFoundException.error, + }) + ); + expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); + expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); + }); + }); + describe('search', () => { test('should resolve immediately if first call returns full result', async () => { const responses = [ @@ -342,7 +375,8 @@ describe('EnhancedSearchInterceptor', () => { { time: 10, value: { - error: 'oh no', + statusCode: 500, + message: 'oh no', id: 1, }, isError: true, @@ -364,7 +398,8 @@ describe('EnhancedSearchInterceptor', () => { await timeTravel(10); expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBe(responses[1].value); + expect(error.mock.calls[0][0]).toBeInstanceOf(Error); + expect((error.mock.calls[0][0] as Error).message).toBe('oh no'); expect(fetchMock).toHaveBeenCalledTimes(2); expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); }); diff --git a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts index 3230895da7705..b2ddd0310f8f5 100644 --- a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts +++ b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts @@ -7,6 +7,10 @@ import { enhancedEsSearchStrategyProvider } from './es_search_strategy'; import { BehaviorSubject } from 'rxjs'; import { SearchStrategyDependencies } from '../../../../../src/plugins/data/server/search'; +import { KbnServerError } from '../../../../../src/plugins/kibana_utils/server'; +import { ElasticsearchClientError, ResponseError } from '@elastic/elasticsearch/lib/errors'; +import * as indexNotFoundException from '../../../../../src/plugins/data/common/search/test_data/index_not_found_exception.json'; +import * as xContentParseException from '../../../../../src/plugins/data/common/search/test_data/x_content_parse_exception.json'; const mockAsyncResponse = { body: { @@ -145,6 +149,54 @@ describe('ES search strategy', () => { expect(request).toHaveProperty('wait_for_completion_timeout'); expect(request).toHaveProperty('keep_alive'); }); + + it('throws normalized error if ResponseError is thrown', async () => { + const errResponse = new ResponseError({ + body: indexNotFoundException, + statusCode: 404, + headers: {}, + warnings: [], + meta: {} as any, + }); + + mockSubmitCaller.mockRejectedValue(errResponse); + + const params = { index: 'logstash-*', body: { query: {} } }; + const esSearch = await enhancedEsSearchStrategyProvider(mockConfig$, mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.search({ params }, {}, mockDeps).toPromise(); + } catch (e) { + err = e; + } + expect(mockSubmitCaller).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(404); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(indexNotFoundException); + }); + + it('throws normalized error if Error is thrown', async () => { + const errResponse = new Error('not good'); + + mockSubmitCaller.mockRejectedValue(errResponse); + + const params = { index: 'logstash-*', body: { query: {} } }; + const esSearch = await enhancedEsSearchStrategyProvider(mockConfig$, mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.search({ params }, {}, mockDeps).toPromise(); + } catch (e) { + err = e; + } + expect(mockSubmitCaller).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(500); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(undefined); + }); }); describe('cancel', () => { @@ -160,6 +212,33 @@ describe('ES search strategy', () => { const request = mockDeleteCaller.mock.calls[0][0]; expect(request).toEqual({ id }); }); + + it('throws normalized error on ResponseError', async () => { + const errResponse = new ResponseError({ + body: xContentParseException, + statusCode: 400, + headers: {}, + warnings: [], + meta: {} as any, + }); + mockDeleteCaller.mockRejectedValue(errResponse); + + const id = 'some_id'; + const esSearch = await enhancedEsSearchStrategyProvider(mockConfig$, mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.cancel!(id, {}, mockDeps); + } catch (e) { + err = e; + } + + expect(mockDeleteCaller).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(400); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(xContentParseException); + }); }); describe('extend', () => { @@ -176,5 +255,27 @@ describe('ES search strategy', () => { const request = mockGetCaller.mock.calls[0][0]; expect(request).toEqual({ id, keep_alive: keepAlive }); }); + + it('throws normalized error on ElasticsearchClientError', async () => { + const errResponse = new ElasticsearchClientError('something is wrong with EsClient'); + mockGetCaller.mockRejectedValue(errResponse); + + const id = 'some_other_id'; + const keepAlive = '1d'; + const esSearch = await enhancedEsSearchStrategyProvider(mockConfig$, mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.extend!(id, keepAlive, {}, mockDeps); + } catch (e) { + err = e; + } + + expect(mockGetCaller).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(500); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(undefined); + }); }); }); diff --git a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts index 54ed59b30952a..694d9807b5a4d 100644 --- a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts +++ b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts @@ -6,7 +6,7 @@ import type { Observable } from 'rxjs'; import type { IScopedClusterClient, Logger, SharedGlobalConfig } from 'kibana/server'; -import { first, tap } from 'rxjs/operators'; +import { catchError, first, tap } from 'rxjs/operators'; import { SearchResponse } from 'elasticsearch'; import { from } from 'rxjs'; import type { @@ -33,7 +33,7 @@ import { } from './request_utils'; import { toAsyncKibanaSearchResponse } from './response_utils'; import { AsyncSearchResponse } from './types'; -import { KbnServerError } from '../../../../../src/plugins/kibana_utils/server'; +import { getKbnServerError, KbnServerError } from '../../../../../src/plugins/kibana_utils/server'; export const enhancedEsSearchStrategyProvider = ( config$: Observable, @@ -41,7 +41,11 @@ export const enhancedEsSearchStrategyProvider = ( usage?: SearchUsage ): ISearchStrategy => { async function cancelAsyncSearch(id: string, esClient: IScopedClusterClient) { - await esClient.asCurrentUser.asyncSearch.delete({ id }); + try { + await esClient.asCurrentUser.asyncSearch.delete({ id }); + } catch (e) { + throw getKbnServerError(e); + } } function asyncSearch( @@ -70,7 +74,10 @@ export const enhancedEsSearchStrategyProvider = ( return pollSearch(search, cancel, options).pipe( tap((response) => (id = response.id)), - tap(searchUsageObserver(logger, usage)) + tap(searchUsageObserver(logger, usage)), + catchError((e) => { + throw getKbnServerError(e); + }) ); } @@ -90,40 +97,72 @@ export const enhancedEsSearchStrategyProvider = ( ...params, }; - const promise = esClient.asCurrentUser.transport.request({ - method, - path, - body, - querystring, - }); + try { + const promise = esClient.asCurrentUser.transport.request({ + method, + path, + body, + querystring, + }); - const esResponse = await shimAbortSignal(promise, options?.abortSignal); - const response = esResponse.body as SearchResponse; - return { - rawResponse: response, - ...getTotalLoaded(response), - }; + const esResponse = await shimAbortSignal(promise, options?.abortSignal); + const response = esResponse.body as SearchResponse; + return { + rawResponse: response, + ...getTotalLoaded(response), + }; + } catch (e) { + throw getKbnServerError(e); + } } return { + /** + * @param request + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Observable>` + * @throws `KbnServerError` + */ search: (request, options: IAsyncSearchOptions, deps) => { logger.debug(`search ${JSON.stringify(request.params) || request.id}`); + if (request.indexType && request.indexType !== 'rollup') { + throw new KbnServerError('Unknown indexType', 400); + } if (request.indexType === undefined) { return asyncSearch(request, options, deps); - } else if (request.indexType === 'rollup') { - return from(rollupSearch(request, options, deps)); } else { - throw new KbnServerError('Unknown indexType', 400); + return from(rollupSearch(request, options, deps)); } }, + /** + * @param id async search ID to cancel, as returned from _async_search API + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Promise` + * @throws `KbnServerError` + */ cancel: async (id, options, { esClient }) => { logger.debug(`cancel ${id}`); await cancelAsyncSearch(id, esClient); }, + /** + * + * @param id async search ID to extend, as returned from _async_search API + * @param keepAlive + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Promise` + * @throws `KbnServerError` + */ extend: async (id, keepAlive, options, { esClient }) => { logger.debug(`extend ${id} by ${keepAlive}`); - await esClient.asCurrentUser.asyncSearch.get({ id, keep_alive: keepAlive }); + try { + await esClient.asCurrentUser.asyncSearch.get({ id, keep_alive: keepAlive }); + } catch (e) { + throw getKbnServerError(e); + } }, }; }; diff --git a/x-pack/plugins/data_enhanced/tsconfig.json b/x-pack/plugins/data_enhanced/tsconfig.json index c4b09276880d9..29bfd71cb32b4 100644 --- a/x-pack/plugins/data_enhanced/tsconfig.json +++ b/x-pack/plugins/data_enhanced/tsconfig.json @@ -14,7 +14,8 @@ "config.ts", "../../../typings/**/*", // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 - "public/autocomplete/providers/kql_query_suggestion/__fixtures__/*.json" + "public/autocomplete/providers/kql_query_suggestion/__fixtures__/*.json", + "common/search/test_data/*.json" ], "references": [ { "path": "../../../src/core/tsconfig.json" }, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx index 68906e2927a0d..22847843826da 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx @@ -7,6 +7,7 @@ import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { useValues, useActions } from 'kea'; +import { EuiSpacer } from '@elastic/eui'; import { KibanaLogic } from '../../../shared/kibana'; import { FlashMessages } from '../../../shared/flash_messages'; @@ -47,6 +48,7 @@ export const AnalyticsLayout: React.FC = ({ {children} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts index 0901ff2737803..59e33893a18eb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts @@ -9,13 +9,14 @@ import { mockKibanaValues, mockHttpValues, mockFlashMessageHelpers, - expectedAsyncError, } from '../../../__mocks__'; jest.mock('../engine', () => ({ EngineLogic: { values: { engineName: 'test-engine' } }, })); +import { nextTick } from '@kbn/test/jest'; + import { DEFAULT_START_DATE, DEFAULT_END_DATE } from './constants'; import { AnalyticsLogic } from './'; @@ -29,6 +30,11 @@ describe('AnalyticsLogic', () => { dataLoading: true, analyticsUnavailable: false, allTags: [], + recentQueries: [], + topQueries: [], + topQueriesNoResults: [], + topQueriesNoClicks: [], + topQueriesWithClicks: [], totalQueries: 0, totalQueriesNoResults: 0, totalClicks: 0, @@ -37,6 +43,7 @@ describe('AnalyticsLogic', () => { queriesNoResultsPerDay: [], clicksPerDay: [], queriesPerDayForQuery: [], + topClicksForQuery: [], startDate: '', }; @@ -129,16 +136,7 @@ describe('AnalyticsLogic', () => { expect(AnalyticsLogic.values).toEqual({ ...DEFAULT_VALUES, dataLoading: false, - analyticsUnavailable: false, - allTags: ['some-tag'], - startDate: '1970-01-01', - totalClicks: 1000, - totalQueries: 5000, - totalQueriesNoResults: 500, - queriesPerDay: [10, 50, 100], - queriesNoResultsPerDay: [1, 2, 3], - clicksPerDay: [0, 10, 50], - // TODO: Replace this with ...MOCK_ANALYTICS_RESPONSE once all data is set + ...MOCK_ANALYTICS_RESPONSE, }); }); }); @@ -151,12 +149,7 @@ describe('AnalyticsLogic', () => { expect(AnalyticsLogic.values).toEqual({ ...DEFAULT_VALUES, dataLoading: false, - analyticsUnavailable: false, - allTags: ['some-tag'], - startDate: '1970-01-01', - totalQueriesForQuery: 50, - queriesPerDayForQuery: [25, 0, 25], - // TODO: Replace this with ...MOCK_QUERY_RESPONSE once all data is set + ...MOCK_QUERY_RESPONSE, }); }); }); @@ -176,13 +169,12 @@ describe('AnalyticsLogic', () => { }); it('should make an API call and set state based on the response', async () => { - const promise = Promise.resolve(MOCK_ANALYTICS_RESPONSE); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(MOCK_ANALYTICS_RESPONSE)); mount(); jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsDataLoad'); AnalyticsLogic.actions.loadAnalyticsData(); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith( '/api/app_search/engines/test-engine/analytics/queries', @@ -220,25 +212,23 @@ describe('AnalyticsLogic', () => { }); it('calls onAnalyticsUnavailable if analyticsUnavailable is in response', async () => { - const promise = Promise.resolve({ analyticsUnavailable: true }); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve({ analyticsUnavailable: true })); mount(); jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); AnalyticsLogic.actions.loadAnalyticsData(); - await promise; + await nextTick(); expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); }); it('handles errors', async () => { - const promise = Promise.reject('error'); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.reject('error')); mount(); jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); AnalyticsLogic.actions.loadAnalyticsData(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('error'); expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); @@ -258,13 +248,12 @@ describe('AnalyticsLogic', () => { }); it('should make an API call and set state based on the response', async () => { - const promise = Promise.resolve(MOCK_QUERY_RESPONSE); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(MOCK_QUERY_RESPONSE)); mount(); jest.spyOn(AnalyticsLogic.actions, 'onQueryDataLoad'); AnalyticsLogic.actions.loadQueryData('some-query'); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith( '/api/app_search/engines/test-engine/analytics/queries/some-query', @@ -298,25 +287,23 @@ describe('AnalyticsLogic', () => { }); it('calls onAnalyticsUnavailable if analyticsUnavailable is in response', async () => { - const promise = Promise.resolve({ analyticsUnavailable: true }); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve({ analyticsUnavailable: true })); mount(); jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); AnalyticsLogic.actions.loadQueryData('some-query'); - await promise; + await nextTick(); expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); }); it('handles errors', async () => { - const promise = Promise.reject('error'); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.reject('error')); mount(); jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); AnalyticsLogic.actions.loadQueryData('some-query'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('error'); expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.ts index 537de02a0fee5..0caf804ea2a08 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.ts @@ -62,6 +62,36 @@ export const AnalyticsLogic = kea allTags, }, ], + recentQueries: [ + [], + { + onAnalyticsDataLoad: (_, { recentQueries }) => recentQueries, + }, + ], + topQueries: [ + [], + { + onAnalyticsDataLoad: (_, { topQueries }) => topQueries, + }, + ], + topQueriesNoResults: [ + [], + { + onAnalyticsDataLoad: (_, { topQueriesNoResults }) => topQueriesNoResults, + }, + ], + topQueriesNoClicks: [ + [], + { + onAnalyticsDataLoad: (_, { topQueriesNoClicks }) => topQueriesNoClicks, + }, + ], + topQueriesWithClicks: [ + [], + { + onAnalyticsDataLoad: (_, { topQueriesWithClicks }) => topQueriesWithClicks, + }, + ], totalQueries: [ 0, { @@ -110,6 +140,12 @@ export const AnalyticsLogic = kea queriesPerDayForQuery, }, ], + topClicksForQuery: [ + [], + { + onQueryDataLoad: (_, { topClicksForQuery }) => topClicksForQuery, + }, + ], startDate: [ '', { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.scss new file mode 100644 index 0000000000000..f3c503d4b27cb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.scss @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +.analyticsHeader { + flex-wrap: wrap; + + &__filters.euiPageHeaderSection { + width: 100%; + margin: $euiSizeM 0; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx index 6866a89687a74..e82c3aff70119 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx @@ -30,6 +30,8 @@ import { AnalyticsLogic } from '../'; import { DEFAULT_START_DATE, DEFAULT_END_DATE, SERVER_DATE_FORMAT } from '../constants'; import { convertTagsToSelectOptions } from '../utils'; +import './analytics_header.scss'; + interface Props { title: string; } @@ -60,7 +62,7 @@ export const AnalyticsHeader: React.FC = ({ title }) => { const hasInvalidDateRange = startDate > endDate; return ( - + @@ -69,13 +71,13 @@ export const AnalyticsHeader: React.FC = ({ title }) => { - + - + - + = ({ title }) => { fullWidth /> - + { + const { navigateToUrl } = mockKibanaValues; + const preventDefault = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const wrapper = shallow(); + const setSearchValue = (value: string) => + wrapper.find(EuiFieldSearch).simulate('change', { target: { value } }); + + it('renders', () => { + expect(wrapper.find(EuiFieldSearch)).toHaveLength(1); + }); + + it('updates searchValue state on input change', () => { + expect(wrapper.find(EuiFieldSearch).prop('value')).toEqual(''); + + setSearchValue('some-query'); + expect(wrapper.find(EuiFieldSearch).prop('value')).toEqual('some-query'); + }); + + it('sends the user to the query detail page on search', () => { + wrapper.find('form').simulate('submit', { preventDefault }); + + expect(preventDefault).toHaveBeenCalled(); + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/analytics/query_detail/some-query' + ); + }); + + it('falls back to showing the "" query if searchValue is empty', () => { + setSearchValue(''); + wrapper.find('form').simulate('submit', { preventDefault }); + + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/analytics/query_detail/%22%22' // "" gets encoded + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_search.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_search.tsx new file mode 100644 index 0000000000000..fc2639d87a2f9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_search.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiButton, EuiSpacer } from '@elastic/eui'; + +import { KibanaLogic } from '../../../../shared/kibana'; +import { ENGINE_ANALYTICS_QUERY_DETAIL_PATH } from '../../../routes'; +import { generateEnginePath } from '../../engine'; + +export const AnalyticsSearch: React.FC = () => { + const [searchValue, setSearchValue] = useState(''); + + const { navigateToUrl } = useValues(KibanaLogic); + const viewQueryDetails = (e: React.SyntheticEvent) => { + e.preventDefault(); + const query = searchValue || '""'; + navigateToUrl(generateEnginePath(ENGINE_ANALYTICS_QUERY_DETAIL_PATH, { query })); + }; + + return ( +
+ + + setSearchValue(e.target.value)} + placeholder={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetailSearchPlaceholder', + { defaultMessage: 'Go to search term' } + )} + fullWidth + /> + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetailSearchButtonLabel', + { defaultMessage: 'View details' } + )} + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.test.tsx new file mode 100644 index 0000000000000..1814aba7497f6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.test.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { AnalyticsSection } from './'; + +describe('AnalyticsSection', () => { + it('renders', () => { + const wrapper = shallow( + +
Test
+
+ ); + + expect(wrapper.find('h2').text()).toEqual('Lorem ipsum'); + expect(wrapper.find('p').text()).toEqual('Dolor sit amet.'); + expect(wrapper.find('[data-test-subj="HelloWorld"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.tsx new file mode 100644 index 0000000000000..e14ef0b1f2631 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiPageContentBody, EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; + +interface Props { + title: string; + subtitle: string; +} +export const AnalyticsSection: React.FC = ({ title, subtitle, children }) => ( +
+
+ +

{title}

+
+ +

{subtitle}

+
+
+ + {children} +
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx new file mode 100644 index 0000000000000..88f7e858bef62 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mountWithIntl, mockKibanaValues } from '../../../../../__mocks__'; +import '../../../../__mocks__/engine_logic.mock'; + +import React from 'react'; +import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui'; + +import { AnalyticsTable } from './'; + +describe('AnalyticsTable', () => { + const { navigateToUrl } = mockKibanaValues; + + const items = [ + { + key: 'some search', + tags: ['tagA'], + searches: { doc_count: 100 }, + clicks: { doc_count: 10 }, + }, + { + key: 'another search', + tags: ['tagB'], + searches: { doc_count: 99 }, + clicks: { doc_count: 9 }, + }, + { + key: '', + tags: ['tagA', 'tagB'], + searches: { doc_count: 1 }, + clicks: { doc_count: 0 }, + }, + ]; + + it('renders', () => { + const wrapper = mountWithIntl(); + const tableContent = wrapper.find(EuiBasicTable).text(); + + expect(tableContent).toContain('Search term'); + expect(tableContent).toContain('some search'); + expect(tableContent).toContain('another search'); + expect(tableContent).toContain('""'); + + expect(tableContent).toContain('Analytics tags'); + expect(tableContent).toContain('tagA'); + expect(tableContent).toContain('tagB'); + expect(wrapper.find(EuiBadge)).toHaveLength(4); + + expect(tableContent).toContain('Queries'); + expect(tableContent).toContain('100'); + expect(tableContent).toContain('99'); + expect(tableContent).toContain('1'); + expect(tableContent).not.toContain('Clicks'); + }); + + it('renders a clicks column if hasClicks is passed', () => { + const wrapper = mountWithIntl(); + const tableContent = wrapper.find(EuiBasicTable).text(); + + expect(tableContent).toContain('Clicks'); + expect(tableContent).toContain('10'); + expect(tableContent).toContain('9'); + expect(tableContent).toContain('0'); + }); + + it('renders an action column', () => { + const wrapper = mountWithIntl(); + const viewQuery = wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first(); + const editQuery = wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first(); + + viewQuery.simulate('click'); + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/analytics/query_detail/some%20search' + ); + + editQuery.simulate('click'); + // TODO + }); + + it('renders an empty prompt if no items are passed', () => { + const wrapper = mountWithIntl(); + const promptContent = wrapper.find(EuiEmptyPrompt).text(); + + expect(promptContent).toContain('No queries were performed during this time period.'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.tsx new file mode 100644 index 0000000000000..41690dfe26e71 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBasicTable, EuiBasicTableColumn, EuiEmptyPrompt } from '@elastic/eui'; + +import { Query } from '../../types'; +import { + TERM_COLUMN_PROPS, + TAGS_COLUMN, + COUNT_COLUMN_PROPS, + ACTIONS_COLUMN, +} from './shared_columns'; + +interface Props { + items: Query[]; + hasClicks?: boolean; +} +type Columns = Array>; + +export const AnalyticsTable: React.FC = ({ items, hasClicks }) => { + const TERM_COLUMN = { + field: 'key', + ...TERM_COLUMN_PROPS, + }; + + const COUNT_COLUMNS = [ + { + field: 'searches.doc_count', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.queriesColumn', + { defaultMessage: 'Queries' } + ), + ...COUNT_COLUMN_PROPS, + }, + ]; + if (hasClicks) { + COUNT_COLUMNS.push({ + field: 'clicks.doc_count', + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.clicksColumn', { + defaultMessage: 'Clicks', + }), + ...COUNT_COLUMN_PROPS, + }); + } + + return ( + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noQueriesTitle', + { defaultMessage: 'No queries' } + )} + + } + body={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noQueriesDescription', + { defaultMessage: 'No queries were performed during this time period.' } + )} + /> + } + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/index.ts new file mode 100644 index 0000000000000..99363c00caaf7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AnalyticsTable } from './analytics_table'; +export { RecentQueriesTable } from './recent_queries_table'; +export { QueryClicksTable } from './query_clicks_table'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.test.tsx new file mode 100644 index 0000000000000..5909ceec4555c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.test.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiBadge, EuiToolTip } from '@elastic/eui'; + +import { InlineTagsList } from './inline_tags_list'; + +describe('InlineTagsList', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiBadge)).toHaveLength(1); + expect(wrapper.find(EuiBadge).prop('children')).toEqual('test'); + }); + + it('renders >2 badges in a tooltip list', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiBadge)).toHaveLength(3); + expect(wrapper.find(EuiToolTip)).toHaveLength(1); + + expect(wrapper.find(EuiBadge).at(0).prop('children')).toEqual('1'); + expect(wrapper.find(EuiBadge).at(1).prop('children')).toEqual('2'); + expect(wrapper.find(EuiBadge).at(2).prop('children')).toEqual('and 3 more'); + expect(wrapper.find(EuiToolTip).prop('content')).toEqual('3, 4, 5'); + }); + + it('does not render with no tags', () => { + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.tsx new file mode 100644 index 0000000000000..853f04ee1aa77 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBadgeGroup, EuiBadge, EuiToolTip } from '@elastic/eui'; + +import { Query } from '../../types'; + +interface Props { + tags?: Query['tags']; +} +export const InlineTagsList: React.FC = ({ tags }) => { + if (!tags?.length) return null; + + const displayedTags = tags.slice(0, 2); + const tooltipTags = tags.slice(2); + + return ( + + {displayedTags.map((tag: string) => ( + + {tag} + + ))} + {tooltipTags.length > 0 && ( + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.moreTagsBadge', + { + defaultMessage: 'and {moreTagsCount} more', + values: { moreTagsCount: tooltipTags.length }, + } + )} + + + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.test.tsx new file mode 100644 index 0000000000000..9db9c140d7f50 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mountWithIntl } from '../../../../../__mocks__'; +import '../../../../__mocks__/engine_logic.mock'; + +import React from 'react'; +import { EuiBasicTable, EuiLink, EuiBadge, EuiEmptyPrompt } from '@elastic/eui'; + +import { QueryClicksTable } from './'; + +describe('QueryClicksTable', () => { + const items = [ + { + key: 'some-document', + document: { + engine: 'some-engine', + id: 'some-document', + }, + tags: ['tagA'], + doc_count: 10, + }, + { + key: 'another-document', + document: { + engine: 'another-engine', + id: 'another-document', + }, + tags: ['tagB'], + doc_count: 5, + }, + { + key: 'deleted-document', + tags: [], + doc_count: 1, + }, + ]; + + it('renders', () => { + const wrapper = mountWithIntl(); + const tableContent = wrapper.find(EuiBasicTable).text(); + + expect(tableContent).toContain('Documents'); + expect(tableContent).toContain('some-document'); + expect(tableContent).toContain('another-document'); + expect(tableContent).toContain('deleted-document'); + + expect(wrapper.find(EuiLink).first().prop('href')).toEqual( + '/app/enterprise_search/engines/some-engine/documents/some-document' + ); + expect(wrapper.find(EuiLink).last().prop('href')).toEqual( + '/app/enterprise_search/engines/another-engine/documents/another-document' + ); + // deleted-document should not have a link + + expect(tableContent).toContain('Analytics tags'); + expect(tableContent).toContain('tagA'); + expect(tableContent).toContain('tagB'); + expect(wrapper.find(EuiBadge)).toHaveLength(2); + + expect(tableContent).toContain('Clicks'); + expect(tableContent).toContain('10'); + expect(tableContent).toContain('5'); + expect(tableContent).toContain('1'); + }); + + it('renders an empty prompt if no items are passed', () => { + const wrapper = mountWithIntl(); + const promptContent = wrapper.find(EuiEmptyPrompt).text(); + + expect(promptContent).toContain('No clicks'); + expect(promptContent).toContain('No documents have been clicked from this query.'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.tsx new file mode 100644 index 0000000000000..e032e42eca3a6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { EuiBasicTable, EuiBasicTableColumn, EuiEmptyPrompt } from '@elastic/eui'; + +import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; +import { ENGINE_DOCUMENT_DETAIL_PATH } from '../../../../routes'; +import { generateEnginePath } from '../../../engine'; +import { DOCUMENTS_TITLE } from '../../../documents'; + +import { QueryClick } from '../../types'; +import { FIRST_COLUMN_PROPS, TAGS_COLUMN, COUNT_COLUMN_PROPS } from './shared_columns'; + +interface Props { + items: QueryClick[]; +} +type Columns = Array>; + +export const QueryClicksTable: React.FC = ({ items }) => { + const DOCUMENT_COLUMN = { + ...FIRST_COLUMN_PROPS, + field: 'document', + name: DOCUMENTS_TITLE, + render: (document: QueryClick['document'], query: QueryClick) => { + return document ? ( + + {document.id} + + ) : ( + query.key + ); + }, + }; + + const CLICKS_COLUMN = { + ...COUNT_COLUMN_PROPS, + field: 'doc_count', + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.clicksColumn', { + defaultMessage: 'Clicks', + }), + }; + + return ( + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noClicksTitle', + { defaultMessage: 'No clicks' } + )} + + } + body={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noClicksDescription', + { defaultMessage: 'No documents have been clicked from this query.' } + )} + /> + } + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx new file mode 100644 index 0000000000000..261d0f75c1cee --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mountWithIntl, mockKibanaValues } from '../../../../../__mocks__'; +import '../../../../__mocks__/engine_logic.mock'; + +import React from 'react'; +import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui'; + +import { RecentQueriesTable } from './'; + +describe('RecentQueriesTable', () => { + const { navigateToUrl } = mockKibanaValues; + + const items = [ + { + query_string: 'some search', + timestamp: '1970-01-03T12:00:00Z', + tags: ['tagA'], + document_ids: ['documentA', 'documentB'], + }, + { + query_string: 'another search', + timestamp: '1970-01-02T12:00:00Z', + tags: ['tagB'], + document_ids: ['documentC'], + }, + { + query_string: '', + timestamp: '1970-01-01T12:00:00Z', + tags: ['tagA', 'tagB'], + document_ids: ['documentA', 'documentB', 'documentC'], + }, + ]; + + it('renders', () => { + const wrapper = mountWithIntl(); + const tableContent = wrapper.find(EuiBasicTable).text(); + + expect(tableContent).toContain('Search term'); + expect(tableContent).toContain('some search'); + expect(tableContent).toContain('another search'); + expect(tableContent).toContain('""'); + + expect(tableContent).toContain('Time'); + expect(tableContent).toContain('1/3/1970'); + expect(tableContent).toContain('1/2/1970'); + expect(tableContent).toContain('1/1/1970'); + + expect(tableContent).toContain('Analytics tags'); + expect(tableContent).toContain('tagA'); + expect(tableContent).toContain('tagB'); + expect(wrapper.find(EuiBadge)).toHaveLength(4); + + expect(tableContent).toContain('Results'); + expect(tableContent).toContain('2'); + expect(tableContent).toContain('1'); + expect(tableContent).toContain('3'); + }); + + it('renders an action column', () => { + const wrapper = mountWithIntl(); + const viewQuery = wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first(); + const editQuery = wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first(); + + viewQuery.simulate('click'); + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/analytics/query_detail/some%20search' + ); + + editQuery.simulate('click'); + // TODO + }); + + it('renders an empty prompt if no items are passed', () => { + const wrapper = mountWithIntl(); + const promptContent = wrapper.find(EuiEmptyPrompt).text(); + + expect(promptContent).toContain('No recent queries'); + expect(promptContent).toContain('Queries will appear here as they are received.'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.tsx new file mode 100644 index 0000000000000..b0dc8254c084b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedDate, FormattedTime } from '@kbn/i18n/react'; +import { EuiBasicTable, EuiBasicTableColumn, EuiEmptyPrompt } from '@elastic/eui'; + +import { RecentQuery } from '../../types'; +import { + TERM_COLUMN_PROPS, + TAGS_COLUMN, + COUNT_COLUMN_PROPS, + ACTIONS_COLUMN, +} from './shared_columns'; + +interface Props { + items: RecentQuery[]; +} +type Columns = Array>; + +export const RecentQueriesTable: React.FC = ({ items }) => { + const TERM_COLUMN = { + ...TERM_COLUMN_PROPS, + field: 'query_string', + }; + + const TIME_COLUMN = { + field: 'timestamp', + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.timeColumn', { + defaultMessage: 'Time', + }), + render: (timestamp: RecentQuery['timestamp']) => { + const date = new Date(timestamp); + return ( + <> + + + ); + }, + width: '175px', + }; + + const RESULTS_COLUMN = { + ...COUNT_COLUMN_PROPS, + field: 'document_ids', + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.resultsColumn', { + defaultMessage: 'Results', + }), + render: (documents: RecentQuery['document_ids']) => documents.length, + }; + + return ( + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noRecentQueriesTitle', + { defaultMessage: 'No recent queries' } + )} + + } + body={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noRecentQueriesDescription', + { defaultMessage: 'Queries will appear here as they are received.' } + )} + /> + } + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx new file mode 100644 index 0000000000000..16743405e0b5e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; + +import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; +import { KibanaLogic } from '../../../../../shared/kibana'; +import { ENGINE_ANALYTICS_QUERY_DETAIL_PATH } from '../../../../routes'; +import { generateEnginePath } from '../../../engine'; + +import { Query, RecentQuery } from '../../types'; +import { InlineTagsList } from './inline_tags_list'; + +/** + * Shared columns / column properties between separate analytics tables + */ + +export const FIRST_COLUMN_PROPS = { + truncateText: true, + width: '25%', + mobileOptions: { + enlarge: true, + width: '100%', + }, +}; + +export const TERM_COLUMN_PROPS = { + // Field key changes per-table + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.termColumn', { + defaultMessage: 'Search term', + }), + render: (query: Query['key']) => { + if (!query) query = '""'; + return ( + + {query} + + ); + }, + ...FIRST_COLUMN_PROPS, +}; + +export const ACTIONS_COLUMN = { + width: '120px', + actions: [ + { + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.viewAction', { + defaultMessage: 'View', + }), + description: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.viewTooltip', + { defaultMessage: 'View query analytics' } + ), + type: 'icon', + icon: 'popout', + color: 'primary', + onClick: (item: Query | RecentQuery) => { + const { navigateToUrl } = KibanaLogic.values; + + const query = (item as Query).key || (item as RecentQuery).query_string; + navigateToUrl(generateEnginePath(ENGINE_ANALYTICS_QUERY_DETAIL_PATH, { query })); + }, + 'data-test-subj': 'AnalyticsTableViewQueryButton', + }, + { + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.editAction', { + defaultMessage: 'Edit', + }), + description: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.editTooltip', + { defaultMessage: 'Edit query analytics' } + ), + type: 'icon', + icon: 'pencil', + onClick: () => { + // TODO: CurationsLogic + }, + 'data-test-subj': 'AnalyticsTableEditQueryButton', + }, + ], +}; + +export const TAGS_COLUMN = { + field: 'tags', + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.tagsColumn', { + defaultMessage: 'Analytics tags', + }), + truncateText: true, + render: (tags: Query['tags']) => , +}; + +export const COUNT_COLUMN_PROPS = { + dataType: 'number', + width: '100px', +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts index ae9c9ca450638..ddad726b04c26 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts @@ -7,4 +7,7 @@ export { AnalyticsCards } from './analytics_cards'; export { AnalyticsChart } from './analytics_chart'; export { AnalyticsHeader } from './analytics_header'; +export { AnalyticsSection } from './analytics_section'; +export { AnalyticsSearch } from './analytics_search'; +export { AnalyticsTable, RecentQueriesTable, QueryClicksTable } from './analytics_tables'; export { AnalyticsUnavailable } from './analytics_unavailable'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/types.ts index a3977a0c07a80..8bee8fd4407b7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/types.ts @@ -4,27 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -interface Query { - doc_count: number; +export interface Query { key: string; - clicks?: { doc_count: number }; - searches?: { doc_count: number }; tags?: string[]; + searches?: { doc_count: number }; + clicks?: { doc_count: number }; } -interface QueryClick extends Query { +export interface QueryClick extends Query { document?: { id: string; engine: string; - tags?: string[]; }; } -interface RecentQuery { - document_ids: string[]; +export interface RecentQuery { query_string: string; - tags: string[]; timestamp: string; + tags: string[]; + document_ids: string[]; } /** diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx index 06bf77d35372f..e5bff981cb000 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx @@ -5,12 +5,19 @@ */ import { setMockValues } from '../../../../__mocks__'; +import '../../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow } from 'enzyme'; -import { AnalyticsCards, AnalyticsChart } from '../components'; -import { Analytics } from './'; +import { + AnalyticsCards, + AnalyticsChart, + AnalyticsSection, + AnalyticsTable, + RecentQueriesTable, +} from '../components'; +import { Analytics, ViewAllButton } from './analytics'; describe('Analytics overview', () => { it('renders', () => { @@ -22,10 +29,27 @@ describe('Analytics overview', () => { queriesNoResultsPerDay: [1, 2, 3], clicksPerDay: [0, 1, 5], startDate: '1970-01-01', + topQueries: [], + topQueriesNoResults: [], + topQueriesNoClicks: [], + topQueriesWithClicks: [], + recentQueries: [], }); const wrapper = shallow(); expect(wrapper.find(AnalyticsCards)).toHaveLength(1); expect(wrapper.find(AnalyticsChart)).toHaveLength(1); + expect(wrapper.find(AnalyticsSection)).toHaveLength(3); + expect(wrapper.find(AnalyticsTable)).toHaveLength(4); + expect(wrapper.find(RecentQueriesTable)).toHaveLength(1); + }); + + describe('ViewAllButton', () => { + it('renders', () => { + const to = '/analytics/top_queries'; + const wrapper = shallow(); + + expect(wrapper.prop('to')).toEqual(to); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx index d3c3bff5a2947..e6a3e1ca5809b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx @@ -7,15 +7,32 @@ import React from 'react'; import { useValues } from 'kea'; -import { EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { EuiSpacer, EuiTitle } from '@elastic/eui'; + +import { EuiButtonTo } from '../../../../shared/react_router_helpers'; +import { + ENGINE_ANALYTICS_TOP_QUERIES_PATH, + ENGINE_ANALYTICS_TOP_QUERIES_NO_RESULTS_PATH, + ENGINE_ANALYTICS_TOP_QUERIES_NO_CLICKS_PATH, + ENGINE_ANALYTICS_TOP_QUERIES_WITH_CLICKS_PATH, + ENGINE_ANALYTICS_RECENT_QUERIES_PATH, +} from '../../../routes'; +import { generateEnginePath } from '../../engine'; import { ANALYTICS_TITLE, TOTAL_QUERIES, TOTAL_QUERIES_NO_RESULTS, TOTAL_CLICKS, + TOP_QUERIES, + TOP_QUERIES_NO_RESULTS, + TOP_QUERIES_WITH_CLICKS, + TOP_QUERIES_NO_CLICKS, + RECENT_QUERIES, } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSection, AnalyticsTable, RecentQueriesTable } from '../components'; import { AnalyticsLogic, AnalyticsCards, AnalyticsChart, convertToChartData } from '../'; export const Analytics: React.FC = () => { @@ -27,6 +44,11 @@ export const Analytics: React.FC = () => { queriesNoResultsPerDay, clicksPerDay, startDate, + topQueries, + topQueriesNoResults, + topQueriesWithClicks, + topQueriesNoClicks, + recentQueries, } = useValues(AnalyticsLogic); return ( @@ -72,7 +94,77 @@ export const Analytics: React.FC = () => { /> -

TODO: Analytics overview

+ + +

{TOP_QUERIES}

+
+ + + + +

{TOP_QUERIES_NO_RESULTS}

+
+ + +
+ + + + +

{TOP_QUERIES_WITH_CLICKS}

+
+ + + + +

{TOP_QUERIES_NO_CLICKS}

+
+ + +
+ + + + + + ); }; + +export const ViewAllButton: React.FC<{ to: string }> = ({ to }) => ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.viewAllButtonLabel', { + defaultMessage: 'View all', + })} + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx index 99485340f6b88..7705d342ecdce 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx @@ -13,7 +13,7 @@ import { shallow } from 'enzyme'; import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; -import { AnalyticsCards, AnalyticsChart } from '../components'; +import { AnalyticsCards, AnalyticsChart, QueryClicksTable } from '../components'; import { QueryDetail } from './'; describe('QueryDetail', () => { @@ -41,5 +41,6 @@ describe('QueryDetail', () => { expect(wrapper.find(AnalyticsCards)).toHaveLength(1); expect(wrapper.find(AnalyticsChart)).toHaveLength(1); + expect(wrapper.find(QueryClicksTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx index 53c1dc8b845b1..d5d864f35f681 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx @@ -15,6 +15,7 @@ import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_c import { BreadcrumbTrail } from '../../../../shared/kibana_chrome/generate_breadcrumbs'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSection, QueryClicksTable } from '../components'; import { AnalyticsLogic, AnalyticsCards, AnalyticsChart, convertToChartData } from '../'; const QUERY_DETAIL_TITLE = i18n.translate( @@ -28,7 +29,9 @@ interface Props { export const QueryDetail: React.FC = ({ breadcrumbs }) => { const { query } = useParams() as { query: string }; - const { totalQueriesForQuery, queriesPerDayForQuery, startDate } = useValues(AnalyticsLogic); + const { totalQueriesForQuery, queriesPerDayForQuery, startDate, topClicksForQuery } = useValues( + AnalyticsLogic + ); return ( @@ -63,7 +66,18 @@ export const QueryDetail: React.FC = ({ breadcrumbs }) => { /> -

TODO: Query detail page

+ + +
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.test.tsx index f25b044e8a56f..efd2de9223c98 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.test.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { setMockValues } from '../../../../__mocks__'; + import React from 'react'; import { shallow } from 'enzyme'; +import { RecentQueriesTable } from '../components'; import { RecentQueries } from './'; describe('RecentQueries', () => { it('renders', () => { + setMockValues({ recentQueries: [] }); const wrapper = shallow(); - expect(wrapper.isEmptyRender()).toBe(false); // TODO + expect(wrapper.find(RecentQueriesTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.tsx index 3510a2a0e8221..708863ba0e5c8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.tsx @@ -5,14 +5,20 @@ */ import React from 'react'; +import { useValues } from 'kea'; import { RECENT_QUERIES } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSearch, RecentQueriesTable } from '../components'; +import { AnalyticsLogic } from '../'; export const RecentQueries: React.FC = () => { + const { recentQueries } = useValues(AnalyticsLogic); + return ( -

TODO: Recent queries

+ +
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.test.tsx index 9747609aaf066..754a349c2fe94 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.test.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { setMockValues } from '../../../../__mocks__'; + import React from 'react'; import { shallow } from 'enzyme'; +import { AnalyticsTable } from '../components'; import { TopQueries } from './'; describe('TopQueries', () => { it('renders', () => { + setMockValues({ topQueries: [] }); const wrapper = shallow(); - expect(wrapper.isEmptyRender()).toBe(false); // TODO + expect(wrapper.find(AnalyticsTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx index 3f2867871765c..0814ba16e39dc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx @@ -5,14 +5,20 @@ */ import React from 'react'; +import { useValues } from 'kea'; import { TOP_QUERIES } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSearch, AnalyticsTable } from '../components'; +import { AnalyticsLogic } from '../'; export const TopQueries: React.FC = () => { + const { topQueries } = useValues(AnalyticsLogic); + return ( -

TODO: Top queries

+ +
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.test.tsx index bc55753acf152..f1eb3a2f69a98 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.test.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { setMockValues } from '../../../../__mocks__'; + import React from 'react'; import { shallow } from 'enzyme'; +import { AnalyticsTable } from '../components'; import { TopQueriesNoClicks } from './'; describe('TopQueriesNoClicks', () => { it('renders', () => { + setMockValues({ topQueriesNoClicks: [] }); const wrapper = shallow(); - expect(wrapper.isEmptyRender()).toBe(false); // TODO + expect(wrapper.find(AnalyticsTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx index dc14c4a83bff3..283a790b61571 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx @@ -5,14 +5,20 @@ */ import React from 'react'; +import { useValues } from 'kea'; import { TOP_QUERIES_NO_CLICKS } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSearch, AnalyticsTable } from '../components'; +import { AnalyticsLogic } from '../'; export const TopQueriesNoClicks: React.FC = () => { + const { topQueriesNoClicks } = useValues(AnalyticsLogic); + return ( -

TODO: Top queries with no clicks

+ +
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.test.tsx index 72c718f374714..8e404e34b5f3e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.test.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { setMockValues } from '../../../../__mocks__'; + import React from 'react'; import { shallow } from 'enzyme'; +import { AnalyticsTable } from '../components'; import { TopQueriesNoResults } from './'; describe('TopQueriesNoResults', () => { it('renders', () => { + setMockValues({ topQueriesNoResults: [] }); const wrapper = shallow(); - expect(wrapper.isEmptyRender()).toBe(false); // TODO + expect(wrapper.find(AnalyticsTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx index da8595b43859f..8a54d529b2dd0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx @@ -5,14 +5,20 @@ */ import React from 'react'; +import { useValues } from 'kea'; import { TOP_QUERIES_NO_RESULTS } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSearch, AnalyticsTable } from '../components'; +import { AnalyticsLogic } from '../'; export const TopQueriesNoResults: React.FC = () => { + const { topQueriesNoResults } = useValues(AnalyticsLogic); + return ( -

TODO: Top queries with no results

+ +
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.test.tsx index 74e31e77974ee..714da0d8e45dd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.test.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { setMockValues } from '../../../../__mocks__'; + import React from 'react'; import { shallow } from 'enzyme'; +import { AnalyticsTable } from '../components'; import { TopQueriesWithClicks } from './'; describe('TopQueriesWithClicks', () => { it('renders', () => { + setMockValues({ topQueriesWithClicks: [] }); const wrapper = shallow(); - expect(wrapper.isEmptyRender()).toBe(false); // TODO + expect(wrapper.find(AnalyticsTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx index dc6e837be61d8..73ad9e2e973d8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx @@ -5,14 +5,20 @@ */ import React from 'react'; +import { useValues } from 'kea'; import { TOP_QUERIES_WITH_CLICKS } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSearch, AnalyticsTable } from '../components'; +import { AnalyticsLogic } from '../'; export const TopQueriesWithClicks: React.FC = () => { + const { topQueriesWithClicks } = useValues(AnalyticsLogic); + return ( -

TODO: Top queries with clicks

+ +
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts index cdd055fd367ef..2374bcb1b2d03 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts @@ -4,12 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__'; jest.mock('../../app_logic', () => ({ AppLogic: { @@ -17,9 +12,12 @@ jest.mock('../../app_logic', () => ({ values: { myRole: jest.fn(() => ({})) }, }, })); -import { AppLogic } from '../../app_logic'; +import { nextTick } from '@kbn/test/jest'; + +import { AppLogic } from '../../app_logic'; import { ApiTokenTypes } from './constants'; + import { CredentialsLogic } from './credentials_logic'; describe('CredentialsLogic', () => { @@ -1064,8 +1062,7 @@ describe('CredentialsLogic', () => { it('will call an API endpoint and set the results with the `setCredentialsData` action', async () => { mount(); jest.spyOn(CredentialsLogic.actions, 'setCredentialsData').mockImplementationOnce(() => {}); - const promise = Promise.resolve({ meta, results }); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve({ meta, results })); CredentialsLogic.actions.fetchCredentials(2); expect(http.get).toHaveBeenCalledWith('/api/app_search/credentials', { @@ -1073,17 +1070,16 @@ describe('CredentialsLogic', () => { 'page[current]': 2, }, }); - await promise; + await nextTick(); expect(CredentialsLogic.actions.setCredentialsData).toHaveBeenCalledWith(meta, results); }); it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occured'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occured')); CredentialsLogic.actions.fetchCredentials(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); @@ -1095,12 +1091,11 @@ describe('CredentialsLogic', () => { jest .spyOn(CredentialsLogic.actions, 'setCredentialsDetails') .mockImplementationOnce(() => {}); - const promise = Promise.resolve(credentialsDetails); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(credentialsDetails)); CredentialsLogic.actions.fetchDetails(); expect(http.get).toHaveBeenCalledWith('/api/app_search/credentials/details'); - await promise; + await nextTick(); expect(CredentialsLogic.actions.setCredentialsDetails).toHaveBeenCalledWith( credentialsDetails ); @@ -1108,11 +1103,10 @@ describe('CredentialsLogic', () => { it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occured'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occured')); CredentialsLogic.actions.fetchDetails(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); @@ -1124,23 +1118,21 @@ describe('CredentialsLogic', () => { it('will call an API endpoint and set the results with the `onApiKeyDelete` action', async () => { mount(); jest.spyOn(CredentialsLogic.actions, 'onApiKeyDelete').mockImplementationOnce(() => {}); - const promise = Promise.resolve(); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.resolve()); CredentialsLogic.actions.deleteApiKey(tokenName); expect(http.delete).toHaveBeenCalledWith(`/api/app_search/credentials/${tokenName}`); - await promise; + await nextTick(); expect(CredentialsLogic.actions.onApiKeyDelete).toHaveBeenCalledWith(tokenName); expect(setSuccessMessage).toHaveBeenCalled(); }); it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occured'); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.reject('An error occured')); CredentialsLogic.actions.deleteApiKey(tokenName); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); @@ -1156,14 +1148,13 @@ describe('CredentialsLogic', () => { activeApiToken: createdToken, }); jest.spyOn(CredentialsLogic.actions, 'onApiTokenCreateSuccess'); - const promise = Promise.resolve(createdToken); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(createdToken)); CredentialsLogic.actions.onApiTokenChange(); expect(http.post).toHaveBeenCalledWith('/api/app_search/credentials', { body: JSON.stringify(createdToken), }); - await promise; + await nextTick(); expect(CredentialsLogic.actions.onApiTokenCreateSuccess).toHaveBeenCalledWith(createdToken); expect(setSuccessMessage).toHaveBeenCalled(); }); @@ -1184,25 +1175,23 @@ describe('CredentialsLogic', () => { }, }); jest.spyOn(CredentialsLogic.actions, 'onApiTokenUpdateSuccess'); - const promise = Promise.resolve(updatedToken); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve(updatedToken)); CredentialsLogic.actions.onApiTokenChange(); expect(http.put).toHaveBeenCalledWith('/api/app_search/credentials/test-key', { body: JSON.stringify(updatedToken), }); - await promise; + await nextTick(); expect(CredentialsLogic.actions.onApiTokenUpdateSuccess).toHaveBeenCalledWith(updatedToken); expect(setSuccessMessage).toHaveBeenCalled(); }); it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occured'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('An error occured')); CredentialsLogic.actions.onApiTokenChange(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts index 2256d5ae7946a..e1b562d9561ee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts @@ -6,6 +6,7 @@ import { LogicMounter, mockHttpValues } from '../../../__mocks__'; +import { nextTick } from '@kbn/test/jest'; import dedent from 'dedent'; jest.mock('./utils', () => ({ @@ -443,10 +444,10 @@ describe('DocumentCreationLogic', () => { }); it('should set and show summary from the returned response', async () => { - const promise = http.post.mockReturnValueOnce(Promise.resolve(mockValidResponse)); + http.post.mockReturnValueOnce(Promise.resolve(mockValidResponse)); await DocumentCreationLogic.actions.uploadDocuments({ documents: mockValidDocuments }); - await promise; + await nextTick(); expect(DocumentCreationLogic.actions.setSummary).toHaveBeenCalledWith(mockValidResponse); expect(DocumentCreationLogic.actions.setCreationStep).toHaveBeenCalledWith( @@ -462,7 +463,7 @@ describe('DocumentCreationLogic', () => { }); it('handles API errors', async () => { - const promise = http.post.mockReturnValueOnce( + http.post.mockReturnValueOnce( Promise.reject({ body: { statusCode: 400, @@ -473,7 +474,7 @@ describe('DocumentCreationLogic', () => { ); await DocumentCreationLogic.actions.uploadDocuments({ documents: [{}] }); - await promise; + await nextTick(); expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith( '[400 Bad Request] Invalid request payload JSON format' @@ -481,10 +482,10 @@ describe('DocumentCreationLogic', () => { }); it('handles client-side errors', async () => { - const promise = (http.post as jest.Mock).mockReturnValueOnce(new Error()); + (http.post as jest.Mock).mockReturnValueOnce(new Error()); await DocumentCreationLogic.actions.uploadDocuments({ documents: [{}] }); - await promise; + await nextTick(); expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith( "Cannot read property 'total' of undefined" @@ -493,14 +494,14 @@ describe('DocumentCreationLogic', () => { // NOTE: I can't seem to reproduce this in a production setting. it('handles errors returned from the API', async () => { - const promise = http.post.mockReturnValueOnce( + http.post.mockReturnValueOnce( Promise.resolve({ errors: ['JSON cannot be empty'], }) ); await DocumentCreationLogic.actions.uploadDocuments({ documents: [{}] }); - await promise; + await nextTick(); expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith([ 'JSON cannot be empty', @@ -536,12 +537,12 @@ describe('DocumentCreationLogic', () => { }); it('should correctly merge multiple API calls into a single summary obj', async () => { - const promise = (http.post as jest.Mock) + (http.post as jest.Mock) .mockReturnValueOnce(mockFirstResponse) .mockReturnValueOnce(mockSecondResponse); await DocumentCreationLogic.actions.uploadDocuments({ documents: largeDocumentsArray }); - await promise; + await nextTick(); expect(http.post).toHaveBeenCalledTimes(2); expect(DocumentCreationLogic.actions.setSummary).toHaveBeenCalledWith({ @@ -562,12 +563,12 @@ describe('DocumentCreationLogic', () => { }); it('should correctly merge response errors', async () => { - const promise = (http.post as jest.Mock) + (http.post as jest.Mock) .mockReturnValueOnce({ ...mockFirstResponse, errors: ['JSON cannot be empty'] }) .mockReturnValueOnce({ ...mockSecondResponse, errors: ['Too large to render'] }); await DocumentCreationLogic.actions.uploadDocuments({ documents: largeDocumentsArray }); - await promise; + await nextTick(); expect(http.post).toHaveBeenCalledTimes(2); expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith([ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts index e33cd9b0e9e71..3a8861ee1e20e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts @@ -9,10 +9,11 @@ import { mockHttpValues, mockKibanaValues, mockFlashMessageHelpers, - expectedAsyncError, } from '../../../__mocks__'; import { mockEngineValues } from '../../__mocks__'; +import { nextTick } from '@kbn/test/jest'; + import { DocumentDetailLogic } from './document_detail_logic'; import { InternalSchemaTypes } from '../../../shared/types'; @@ -56,23 +57,21 @@ describe('DocumentDetailLogic', () => { it('will call an API endpoint and then store the result', async () => { const fields = [{ name: 'name', value: 'python', type: 'string' }]; jest.spyOn(DocumentDetailLogic.actions, 'setFields'); - const promise = Promise.resolve({ fields }); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve({ fields })); DocumentDetailLogic.actions.getDocumentDetails('1'); expect(http.get).toHaveBeenCalledWith(`/api/app_search/engines/engine1/documents/1`); - await promise; + await nextTick(); expect(DocumentDetailLogic.actions.setFields).toHaveBeenCalledWith(fields); }); it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occurred'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occurred')); DocumentDetailLogic.actions.getDocumentDetails('1'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occurred', { isQueued: true }); expect(navigateToUrl).toHaveBeenCalledWith('/engines/engine1/documents'); @@ -81,13 +80,11 @@ describe('DocumentDetailLogic', () => { describe('deleteDocument', () => { let confirmSpy: any; - let promise: Promise; beforeEach(() => { confirmSpy = jest.spyOn(window, 'confirm'); confirmSpy.mockImplementation(jest.fn(() => true)); - promise = Promise.resolve({}); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.resolve({})); }); afterEach(() => { @@ -99,7 +96,7 @@ describe('DocumentDetailLogic', () => { DocumentDetailLogic.actions.deleteDocument('1'); expect(http.delete).toHaveBeenCalledWith(`/api/app_search/engines/engine1/documents/1`); - await promise; + await nextTick(); expect(setQueuedSuccessMessage).toHaveBeenCalledWith( 'Successfully marked document for deletion. It will be deleted momentarily.' ); @@ -113,16 +110,15 @@ describe('DocumentDetailLogic', () => { DocumentDetailLogic.actions.deleteDocument('1'); expect(http.delete).not.toHaveBeenCalled(); - await promise; + await nextTick(); }); it('handles errors', async () => { mount(); - promise = Promise.reject('An error occured'); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.reject('An error occured')); DocumentDetailLogic.actions.deleteDocument('1'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts index 48cbaeef70c1a..616dae98e29f2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LogicMounter, mockHttpValues, expectedAsyncError } from '../../../__mocks__'; +import { LogicMounter, mockHttpValues } from '../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; import { EngineLogic } from './'; @@ -172,11 +174,10 @@ describe('EngineLogic', () => { it('fetches and sets engine data', async () => { mount({ engineName: 'some-engine' }); jest.spyOn(EngineLogic.actions, 'setEngineData'); - const promise = Promise.resolve(mockEngineData); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(mockEngineData)); EngineLogic.actions.initializeEngine(); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine'); expect(EngineLogic.actions.setEngineData).toHaveBeenCalledWith(mockEngineData); @@ -185,11 +186,10 @@ describe('EngineLogic', () => { it('handles errors', async () => { mount(); jest.spyOn(EngineLogic.actions, 'setEngineNotFound'); - const promise = Promise.reject('An error occured'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occured')); EngineLogic.actions.initializeEngine(); - await expectedAsyncError(promise); + await nextTick(); expect(EngineLogic.actions.setEngineNotFound).toHaveBeenCalledWith(true); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts index b6620756699d5..9832387a563e3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts @@ -4,17 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockHttpValues, - mockFlashMessageHelpers, - expectedAsyncError, -} from '../../../__mocks__'; +import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; jest.mock('../engine', () => ({ EngineLogic: { values: { engineName: 'some-engine' } }, })); +import { nextTick } from '@kbn/test/jest'; + import { EngineOverviewLogic } from './'; describe('EngineOverviewLogic', () => { @@ -85,11 +82,10 @@ describe('EngineOverviewLogic', () => { it('fetches data and calls onPollingSuccess', async () => { mount(); jest.spyOn(EngineOverviewLogic.actions, 'onPollingSuccess'); - const promise = Promise.resolve(mockEngineMetrics); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(mockEngineMetrics)); EngineOverviewLogic.actions.pollForOverviewMetrics(); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/overview'); expect(EngineOverviewLogic.actions.onPollingSuccess).toHaveBeenCalledWith( @@ -99,11 +95,10 @@ describe('EngineOverviewLogic', () => { it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occurred'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occurred')); EngineOverviewLogic.actions.pollForOverviewMetrics(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occurred'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.test.ts index 5a83717aa0030..2e22c9b76cf6f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.test.ts @@ -6,6 +6,8 @@ import { LogicMounter, mockHttpValues } from '../../../__mocks__'; +import { nextTick } from '@kbn/test/jest'; + import { EngineDetails } from '../engine/types'; import { EnginesLogic } from './'; @@ -124,13 +126,12 @@ describe('EnginesLogic', () => { describe('loadEngines', () => { it('should call the engines API endpoint and set state based on the results', async () => { - const promise = Promise.resolve(MOCK_ENGINES_API_RESPONSE); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(MOCK_ENGINES_API_RESPONSE)); mount({ enginesPage: 10 }); jest.spyOn(EnginesLogic.actions, 'onEnginesLoad'); EnginesLogic.actions.loadEngines(); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith('/api/app_search/engines', { query: { type: 'indexed', pageIndex: 10 }, @@ -144,13 +145,12 @@ describe('EnginesLogic', () => { describe('loadMetaEngines', () => { it('should call the engines API endpoint and set state based on the results', async () => { - const promise = Promise.resolve(MOCK_ENGINES_API_RESPONSE); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(MOCK_ENGINES_API_RESPONSE)); mount({ metaEnginesPage: 99 }); jest.spyOn(EnginesLogic.actions, 'onMetaEnginesLoad'); EnginesLogic.actions.loadMetaEngines(); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith('/api/app_search/engines', { query: { type: 'meta', pageIndex: 99 }, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts index bfdca6791edc1..18ab05a3676c6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts @@ -4,12 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockHttpValues, - mockFlashMessageHelpers, - expectedAsyncError, -} from '../../../__mocks__'; +import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; import { LogRetentionOptions } from './types'; import { LogRetentionLogic } from './log_retention_logic'; @@ -202,8 +199,7 @@ describe('LogRetentionLogic', () => { it('will call an API endpoint and update log retention', async () => { jest.spyOn(LogRetentionLogic.actions, 'updateLogRetention'); - const promise = Promise.resolve(TYPICAL_SERVER_LOG_RETENTION); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve(TYPICAL_SERVER_LOG_RETENTION)); LogRetentionLogic.actions.saveLogRetention(LogRetentionOptions.Analytics, true); @@ -215,7 +211,7 @@ describe('LogRetentionLogic', () => { }), }); - await promise; + await nextTick(); expect(LogRetentionLogic.actions.updateLogRetention).toHaveBeenCalledWith( TYPICAL_CLIENT_LOG_RETENTION ); @@ -224,11 +220,10 @@ describe('LogRetentionLogic', () => { }); it('handles errors', async () => { - const promise = Promise.reject('An error occured'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('An error occured')); LogRetentionLogic.actions.saveLogRetention(LogRetentionOptions.Analytics, true); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); expect(LogRetentionLogic.actions.clearLogRetentionUpdating).toHaveBeenCalled(); @@ -276,14 +271,13 @@ describe('LogRetentionLogic', () => { .spyOn(LogRetentionLogic.actions, 'updateLogRetention') .mockImplementationOnce(() => {}); - const promise = Promise.resolve(TYPICAL_SERVER_LOG_RETENTION); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(TYPICAL_SERVER_LOG_RETENTION)); LogRetentionLogic.actions.fetchLogRetention(); expect(LogRetentionLogic.values.isLogRetentionUpdating).toBe(true); expect(http.get).toHaveBeenCalledWith('/api/app_search/log_settings'); - await promise; + await nextTick(); expect(LogRetentionLogic.actions.updateLogRetention).toHaveBeenCalledWith( TYPICAL_CLIENT_LOG_RETENTION ); @@ -293,11 +287,10 @@ describe('LogRetentionLogic', () => { it('handles errors', async () => { mount(); jest.spyOn(LogRetentionLogic.actions, 'clearLogRetentionUpdating'); - const promise = Promise.reject('An error occured'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occured')); LogRetentionLogic.actions.fetchLogRetention(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); expect(LogRetentionLogic.actions.clearLogRetentionUpdating).toHaveBeenCalled(); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/indexing_status/indexing_status_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/indexing_status/indexing_status_logic.test.ts index 0a80f8e361025..cfff8cc557836 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/indexing_status/indexing_status_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/indexing_status/indexing_status_logic.test.ts @@ -4,12 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; import { IndexingStatusLogic } from './indexing_status_logic'; @@ -57,37 +54,34 @@ describe('IndexingStatusLogic', () => { it('calls API and sets values', async () => { const setIndexingStatusSpy = jest.spyOn(IndexingStatusLogic.actions, 'setIndexingStatus'); - const promise = Promise.resolve(mockStatusResponse); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(mockStatusResponse)); IndexingStatusLogic.actions.fetchIndexingStatus({ statusPath, onComplete }); jest.advanceTimersByTime(TIMEOUT); expect(http.get).toHaveBeenCalledWith(statusPath); - await promise; + await nextTick(); expect(setIndexingStatusSpy).toHaveBeenCalledWith(mockStatusResponse); }); it('handles error', async () => { - const promise = Promise.reject('An error occured'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occured')); IndexingStatusLogic.actions.fetchIndexingStatus({ statusPath, onComplete }); jest.advanceTimersByTime(TIMEOUT); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); it('handles indexing complete state', async () => { - const promise = Promise.resolve({ ...mockStatusResponse, percentageComplete: 100 }); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve({ ...mockStatusResponse, percentageComplete: 100 })); IndexingStatusLogic.actions.fetchIndexingStatus({ statusPath, onComplete }); jest.advanceTimersByTime(TIMEOUT); - await promise; + await nextTick(); expect(clearInterval).toHaveBeenCalled(); expect(onComplete).toHaveBeenCalledWith(mockStatusResponse.numDocumentsWithErrors); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx index 26bbc8814d108..9af5bfc0c3d40 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx @@ -34,10 +34,16 @@ export const CloudSetupInstructions: React.FC = ({ productName, cloudDepl values={{ editDeploymentLink: cloudDeploymentLink ? ( - edit your deployment + {i18n.translate( + 'xpack.enterpriseSearch.setupGuide.cloud.step1.instruction1LinkText', + { defaultMessage: 'edit your deployment' } + )} ) : ( - 'Visit the Elastic Cloud console' + i18n.translate( + 'xpack.enterpriseSearch.setupGuide.cloud.step1.instruction1LinkText', + { defaultMessage: 'edit your deployment' } + ) ), }} /> @@ -76,7 +82,10 @@ export const CloudSetupInstructions: React.FC = ({ productName, cloudDepl href={`${docLinks.enterpriseSearchBase}/configuration.html`} target="_blank" > - configurable options + {i18n.translate( + 'xpack.enterpriseSearch.setupGuide.cloud.step3.instruction1LinkText', + { defaultMessage: 'configurable options' } + )} ), }} @@ -118,7 +127,10 @@ export const CloudSetupInstructions: React.FC = ({ productName, cloudDepl href={`${docLinks.cloudBase}/ec-configure-index-management.html`} target="_blank" > - configure an index lifecycle policy + {i18n.translate( + 'xpack.enterpriseSearch.setupGuide.cloud.step5.instruction1LinkText', + { defaultMessage: 'configure an index lifecycle policy' } + )} ), }} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts index f5f534807fabf..2ce7eed236840 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts @@ -21,6 +21,7 @@ interface AppValues extends WorkplaceSearchInitialData { interface AppActions { initializeAppData(props: InitialAppData): InitialAppData; setContext(isOrganization: boolean): boolean; + setSourceRestriction(canCreatePersonalSources: boolean): boolean; } const emptyOrg = {} as Organization; @@ -34,6 +35,7 @@ export const AppLogic = kea>({ isFederatedAuth, }), setContext: (isOrganization) => isOrganization, + setSourceRestriction: (canCreatePersonalSources: boolean) => canCreatePersonalSources, }, reducers: { hasInitialized: [ @@ -64,6 +66,10 @@ export const AppLogic = kea>({ emptyAccount, { initializeAppData: (_, { workplaceSearch }) => workplaceSearch?.account || emptyAccount, + setSourceRestriction: (state, canCreatePersonalSources) => ({ + ...state, + canCreatePersonalSources, + }), }, ], }, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index 8a83e9aad5fd9..7357e84f27a41 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -45,9 +45,7 @@ export const WorkplaceSearchNav: React.FC = ({ {NAV.ROLE_MAPPINGS} - - {NAV.SECURITY} - + {NAV.SECURITY} {NAV.SETTINGS} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index e72e28aa47d9b..17fbbf517f347 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -289,6 +289,87 @@ export const DOCUMENTATION_LINK_TITLE = i18n.translate( } ); +export const PRIVATE_SOURCES_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.privateSources.description', + { + defaultMessage: + 'Private sources are connected by users in your organization to create a personalized search experience.', + } +); + +export const PRIVATE_SOURCES_TOGGLE_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.privateSourcesToggle.description', + { + defaultMessage: 'Enable private sources for your organization', + } +); + +export const REMOTE_SOURCES_TOGGLE_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesToggle.text', + { + defaultMessage: 'Enable remote private sources', + } +); + +export const REMOTE_SOURCES_TABLE_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesTable.description', + { + defaultMessage: + 'Remote sources synchronize and store a limited amount of data on disk, with a low impact on storage resources.', + } +); + +export const REMOTE_SOURCES_EMPTY_TABLE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesEmptyTable.title', + { + defaultMessage: 'No remote private sources configured yet', + } +); + +export const STANDARD_SOURCES_TOGGLE_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesToggle.text', + { + defaultMessage: 'Enable standard private sources', + } +); + +export const STANDARD_SOURCES_TABLE_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesTable.description', + { + defaultMessage: + 'Standard sources synchronize and store all searchable data on disk, with a directly correlated impact on storage resources.', + } +); + +export const STANDARD_SOURCES_EMPTY_TABLE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesEmptyTable.title', + { + defaultMessage: 'No standard private sources configured yet', + } +); + +export const SECURITY_UNSAVED_CHANGES_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.unsavedChanges.message', + { + defaultMessage: + 'Your private sources settings have not been saved. Are you sure you want to leave?', + } +); + +export const PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.privateSourcesUpdateConfirmation.text', + { + defaultMessage: 'Updates to private source configuration will take effect immediately.', + } +); + +export const SOURCE_RESTRICTIONS_SUCCESS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.sourceRestrictionsSuccess.message', + { + defaultMessage: 'Successfully updated source restrictions.', + } +); + export const PUBLIC_KEY_LABEL = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.publicKey.label', { @@ -382,6 +463,20 @@ export const SAVE_CHANGES_BUTTON = i18n.translate( } ); +export const SAVE_SETTINGS_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.saveSettings.button', + { + defaultMessage: 'Save settings', + } +); + +export const KEEP_EDITING_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.keepEditing.button', + { + defaultMessage: 'Keep editing', + } +); + export const NAME_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.name.label', { defaultMessage: 'Name', }); @@ -493,6 +588,10 @@ export const UPDATE_BUTTON = i18n.translate( } ); +export const RESET_BUTTON = i18n.translate('xpack.enterpriseSearch.workplaceSearch.reset.button', { + defaultMessage: 'Reset', +}); + export const CONFIGURE_BUTTON = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.configure.button', { @@ -522,6 +621,10 @@ export const PRIVATE_PLATINUM_LICENSE_CALLOUT = i18n.translate( } ); +export const SOURCE = i18n.translate('xpack.enterpriseSearch.workplaceSearch.source.text', { + defaultMessage: 'Source', +}); + export const PRIVATE_SOURCE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.privateSource.text', { @@ -529,6 +632,20 @@ export const PRIVATE_SOURCE = i18n.translate( } ); +export const PRIVATE_SOURCES = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.privateSources.text', + { + defaultMessage: 'Private Sources', + } +); + +export const CONFIRM_CHANGES_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.confirmChanges.text', + { + defaultMessage: 'Confirm changes', + } +); + export const CONNECTORS_HEADER_TITLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.connectors.header.title', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index d10de7a770171..ec1b8cfcba958 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -22,6 +22,7 @@ import { SOURCES_PATH, PERSONAL_SOURCES_PATH, ORG_SETTINGS_PATH, + SECURITY_PATH, } from './routes'; import { SetupGuide } from './views/setup_guide'; @@ -29,6 +30,7 @@ import { ErrorState } from './views/error_state'; import { NotFound } from '../shared/not_found'; import { Overview } from './views/overview'; import { GroupsRouter } from './views/groups'; +import { Security } from './views/security'; import { SourcesRouter } from './views/content_sources'; import { SettingsRouter } from './views/settings'; @@ -102,6 +104,11 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { + + } restrictWidth readOnlyMode={readOnlyMode}> + + + } />} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts index d08f807691c2b..058645bd30862 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts @@ -4,18 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../../../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../../../__mocks__'; import { AppLogic } from '../../../../app_logic'; jest.mock('../../../../app_logic', () => ({ AppLogic: { values: { isOrganization: true } }, })); +import { nextTick } from '@kbn/test/jest'; + import { CustomSource } from '../../../../types'; import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; @@ -271,23 +268,21 @@ describe('AddSourceLogic', () => { describe('getSourceConfigData', () => { it('calls API and sets values', async () => { const setSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'setSourceConfigData'); - const promise = Promise.resolve(sourceConfigData); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(sourceConfigData)); AddSourceLogic.actions.getSourceConfigData('github'); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/org/settings/connectors/github' ); - await promise; + await nextTick(); expect(setSourceConfigDataSpy).toHaveBeenCalledWith(sourceConfigData); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.getSourceConfigData('github'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -302,15 +297,14 @@ describe('AddSourceLogic', () => { AddSourceLogic.actions, 'setSourceConnectData' ); - const promise = Promise.resolve(sourceConnectData); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(sourceConnectData)); AddSourceLogic.actions.getSourceConnectData('github', successCallback); expect(clearFlashMessages).toHaveBeenCalled(); expect(AddSourceLogic.values.buttonLoading).toEqual(true); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/sources/github/prepare'); - await promise; + await nextTick(); expect(setSourceConnectDataSpy).toHaveBeenCalledWith(sourceConnectData); expect(successCallback).toHaveBeenCalledWith(sourceConnectData.oauthUrl); expect(setButtonNotLoadingSpy).toHaveBeenCalled(); @@ -327,11 +321,10 @@ describe('AddSourceLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.getSourceConnectData('github', successCallback); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -343,24 +336,22 @@ describe('AddSourceLogic', () => { AddSourceLogic.actions, 'setSourceConnectData' ); - const promise = Promise.resolve(sourceConnectData); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(sourceConnectData)); AddSourceLogic.actions.getSourceReConnectData('github'); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/org/sources/github/reauth_prepare' ); - await promise; + await nextTick(); expect(setSourceConnectDataSpy).toHaveBeenCalledWith(sourceConnectData); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.getSourceReConnectData('github'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -372,22 +363,20 @@ describe('AddSourceLogic', () => { AddSourceLogic.actions, 'setPreContentSourceConfigData' ); - const promise = Promise.resolve(config); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(config)); AddSourceLogic.actions.getPreContentSourceConfigData('123'); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/pre_sources/123'); - await promise; + await nextTick(); expect(setPreContentSourceConfigDataSpy).toHaveBeenCalledWith(config); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.getPreContentSourceConfigData('123'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -414,8 +403,7 @@ describe('AddSourceLogic', () => { const successCallback = jest.fn(); const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); const setSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'setSourceConfigData'); - const promise = Promise.resolve({ sourceConfigData }); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve({ sourceConfigData })); AddSourceLogic.actions.saveSourceConfig(true, successCallback); @@ -428,7 +416,7 @@ describe('AddSourceLogic', () => { { body: JSON.stringify({ params }) } ); - await promise; + await nextTick(); expect(successCallback).toHaveBeenCalled(); expect(setSourceConfigDataSpy).toHaveBeenCalledWith({ sourceConfigData }); expect(setButtonNotLoadingSpy).toHaveBeenCalled(); @@ -453,11 +441,10 @@ describe('AddSourceLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.saveSourceConfig(true); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -495,8 +482,7 @@ describe('AddSourceLogic', () => { it('calls API and sets values', async () => { const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); const setCustomSourceDataSpy = jest.spyOn(AddSourceLogic.actions, 'setCustomSourceData'); - const promise = Promise.resolve({ sourceConfigData }); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve({ sourceConfigData })); AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); @@ -505,18 +491,17 @@ describe('AddSourceLogic', () => { expect(http.post).toHaveBeenCalledWith('/api/workplace_search/org/create_source', { body: JSON.stringify({ ...params }), }); - await promise; + await nextTick(); expect(setCustomSourceDataSpy).toHaveBeenCalledWith({ sourceConfigData }); expect(successCallback).toHaveBeenCalled(); expect(setButtonNotLoadingSpy).toHaveBeenCalled(); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); - await expectedAsyncError(promise); + await nextTick(); expect(errorCallback).toHaveBeenCalled(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts index aed99bdd950c5..d43afd589468f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts @@ -6,11 +6,7 @@ import { LogicMounter } from '../../../../../__mocks__/kea.mock'; -import { - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../../../../__mocks__'; +import { mockFlashMessageHelpers, mockHttpValues } from '../../../../../__mocks__'; const contentSource = { id: 'source123' }; jest.mock('../../source_logic', () => ({ @@ -22,6 +18,8 @@ jest.mock('../../../../app_logic', () => ({ AppLogic: { values: { isOrganization: true } }, })); +import { nextTick } from '@kbn/test/jest'; + import { exampleResult } from '../../../../__mocks__/content_sources.mock'; import { LEAVE_UNASSIGNED_FIELD } from './constants'; @@ -286,14 +284,13 @@ describe('DisplaySettingsLogic', () => { DisplaySettingsLogic.actions, 'onInitializeDisplaySettings' ); - const promise = Promise.resolve(serverProps); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(serverProps)); DisplaySettingsLogic.actions.initializeDisplaySettings(); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/org/sources/source123/display_settings/config' ); - await promise; + await nextTick(); expect(onInitializeDisplaySettingsSpy).toHaveBeenCalledWith({ ...serverProps, isOrganization: true, @@ -307,14 +304,13 @@ describe('DisplaySettingsLogic', () => { DisplaySettingsLogic.actions, 'onInitializeDisplaySettings' ); - const promise = Promise.resolve(serverProps); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(serverProps)); DisplaySettingsLogic.actions.initializeDisplaySettings(); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/account/sources/source123/display_settings/config' ); - await promise; + await nextTick(); expect(onInitializeDisplaySettingsSpy).toHaveBeenCalledWith({ ...serverProps, isOrganization: false, @@ -322,10 +318,9 @@ describe('DisplaySettingsLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); DisplaySettingsLogic.actions.initializeDisplaySettings(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -337,25 +332,23 @@ describe('DisplaySettingsLogic', () => { DisplaySettingsLogic.actions, 'setServerResponseData' ); - const promise = Promise.resolve(serverProps); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(serverProps)); DisplaySettingsLogic.actions.onInitializeDisplaySettings(serverProps); DisplaySettingsLogic.actions.setServerData(); expect(http.post).toHaveBeenCalledWith(serverProps.serverRoute, { body: JSON.stringify({ ...searchResultConfig }), }); - await promise; + await nextTick(); expect(setServerResponseDataSpy).toHaveBeenCalledWith({ ...serverProps, }); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); DisplaySettingsLogic.actions.setServerData(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts index 2c3aa6114c7da..c9d68201f33ee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts @@ -4,12 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../../../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; const contentSource = { id: 'source123' }; jest.mock('../../source_logic', () => ({ @@ -198,14 +195,13 @@ describe('SchemaLogic', () => { describe('initializeSchema', () => { it('calls API and sets values (org)', async () => { const onInitializeSchemaSpy = jest.spyOn(SchemaLogic.actions, 'onInitializeSchema'); - const promise = Promise.resolve(serverResponse); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.initializeSchema(); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/org/sources/source123/schemas' ); - await promise; + await nextTick(); expect(onInitializeSchemaSpy).toHaveBeenCalledWith(serverResponse); }); @@ -213,22 +209,20 @@ describe('SchemaLogic', () => { AppLogic.values.isOrganization = false; const onInitializeSchemaSpy = jest.spyOn(SchemaLogic.actions, 'onInitializeSchema'); - const promise = Promise.resolve(serverResponse); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.initializeSchema(); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/account/sources/source123/schemas' ); - await promise; + await nextTick(); expect(onInitializeSchemaSpy).toHaveBeenCalledWith(serverResponse); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); SchemaLogic.actions.initializeSchema(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -297,13 +291,12 @@ describe('SchemaLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject({ error: 'this is an error' }); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject({ error: 'this is an error' })); SchemaLogic.actions.initializeSchemaFieldErrors( mostRecentIndexJob.activeReindexJobId, contentSource.id ); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith({ error: 'this is an error', @@ -352,8 +345,7 @@ describe('SchemaLogic', () => { it('calls API and sets values (org)', async () => { AppLogic.values.isOrganization = true; const onSchemaSetSuccessSpy = jest.spyOn(SchemaLogic.actions, 'onSchemaSetSuccess'); - const promise = Promise.resolve(serverResponse); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.setServerField(schema, ADD); expect(http.post).toHaveBeenCalledWith( @@ -362,7 +354,7 @@ describe('SchemaLogic', () => { body: JSON.stringify({ ...schema }), } ); - await promise; + await nextTick(); expect(setSuccessMessage).toHaveBeenCalledWith(SCHEMA_FIELD_ADDED_MESSAGE); expect(onSchemaSetSuccessSpy).toHaveBeenCalledWith(serverResponse); }); @@ -371,8 +363,7 @@ describe('SchemaLogic', () => { AppLogic.values.isOrganization = false; const onSchemaSetSuccessSpy = jest.spyOn(SchemaLogic.actions, 'onSchemaSetSuccess'); - const promise = Promise.resolve(serverResponse); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.setServerField(schema, ADD); expect(http.post).toHaveBeenCalledWith( @@ -381,16 +372,15 @@ describe('SchemaLogic', () => { body: JSON.stringify({ ...schema }), } ); - await promise; + await nextTick(); expect(onSchemaSetSuccessSpy).toHaveBeenCalledWith(serverResponse); }); it('handles error', async () => { const onSchemaSetFormErrorsSpy = jest.spyOn(SchemaLogic.actions, 'onSchemaSetFormErrors'); - const promise = Promise.reject({ message: 'this is an error' }); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject({ message: 'this is an error' })); SchemaLogic.actions.setServerField(schema, ADD); - await expectedAsyncError(promise); + await nextTick(); expect(onSchemaSetFormErrorsSpy).toHaveBeenCalledWith('this is an error'); }); @@ -400,8 +390,7 @@ describe('SchemaLogic', () => { it('calls API and sets values (org)', async () => { AppLogic.values.isOrganization = true; const onSchemaSetSuccessSpy = jest.spyOn(SchemaLogic.actions, 'onSchemaSetSuccess'); - const promise = Promise.resolve(serverResponse); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.setServerField(schema, UPDATE); expect(http.post).toHaveBeenCalledWith( @@ -410,7 +399,7 @@ describe('SchemaLogic', () => { body: JSON.stringify({ ...schema }), } ); - await promise; + await nextTick(); expect(setSuccessMessage).toHaveBeenCalledWith(SCHEMA_UPDATED_MESSAGE); expect(onSchemaSetSuccessSpy).toHaveBeenCalledWith(serverResponse); }); @@ -419,8 +408,7 @@ describe('SchemaLogic', () => { AppLogic.values.isOrganization = false; const onSchemaSetSuccessSpy = jest.spyOn(SchemaLogic.actions, 'onSchemaSetSuccess'); - const promise = Promise.resolve(serverResponse); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.setServerField(schema, UPDATE); expect(http.post).toHaveBeenCalledWith( @@ -429,15 +417,14 @@ describe('SchemaLogic', () => { body: JSON.stringify({ ...schema }), } ); - await promise; + await nextTick(); expect(onSchemaSetSuccessSpy).toHaveBeenCalledWith(serverResponse); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); SchemaLogic.actions.setServerField(schema, UPDATE); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts index e90acd929a990..2e7a028e43aec 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts @@ -9,9 +9,10 @@ import { mockKibanaValues, mockFlashMessageHelpers, mockHttpValues, - expectedAsyncError, } from '../../../__mocks__'; +import { nextTick } from '@kbn/test/jest'; + import { groups } from '../../__mocks__/groups.mock'; import { mockGroupValues } from './__mocks__/group_logic.mock'; import { GroupLogic } from './group_logic'; @@ -229,32 +230,29 @@ describe('GroupLogic', () => { describe('initializeGroup', () => { it('calls API and sets values', async () => { const onInitializeGroupSpy = jest.spyOn(GroupLogic.actions, 'onInitializeGroup'); - const promise = Promise.resolve(group); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(group)); GroupLogic.actions.initializeGroup(sourceIds[0]); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/groups/123'); - await promise; + await nextTick(); expect(onInitializeGroupSpy).toHaveBeenCalledWith(group); }); it('handles 404 error', async () => { - const promise = Promise.reject({ response: { status: 404 } }); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject({ response: { status: 404 } })); GroupLogic.actions.initializeGroup(sourceIds[0]); - await expectedAsyncError(promise); + await nextTick(); expect(navigateToUrl).toHaveBeenCalledWith(GROUPS_PATH); expect(setQueuedErrorMessage).toHaveBeenCalledWith('Unable to find group with ID: "123".'); }); it('handles non-404 error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.initializeGroup(sourceIds[0]); - await expectedAsyncError(promise); + await nextTick(); expect(navigateToUrl).toHaveBeenCalledWith(GROUPS_PATH); expect(setQueuedErrorMessage).toHaveBeenCalledWith('this is an error'); @@ -266,13 +264,12 @@ describe('GroupLogic', () => { GroupLogic.actions.onInitializeGroup(group); }); it('deletes a group', async () => { - const promise = Promise.resolve(true); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.resolve(true)); GroupLogic.actions.deleteGroup(); expect(http.delete).toHaveBeenCalledWith('/api/workplace_search/groups/123'); - await promise; + await nextTick(); expect(navigateToUrl).toHaveBeenCalledWith(GROUPS_PATH); expect(setQueuedSuccessMessage).toHaveBeenCalledWith( 'Group "group" was successfully deleted.' @@ -280,11 +277,10 @@ describe('GroupLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.deleteGroup(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -297,15 +293,14 @@ describe('GroupLogic', () => { }); it('updates name', async () => { const onGroupNameChangedSpy = jest.spyOn(GroupLogic.actions, 'onGroupNameChanged'); - const promise = Promise.resolve(group); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve(group)); GroupLogic.actions.updateGroupName(); expect(http.put).toHaveBeenCalledWith('/api/workplace_search/groups/123', { body: JSON.stringify({ group: { name: 'new name' } }), }); - await promise; + await nextTick(); expect(onGroupNameChangedSpy).toHaveBeenCalledWith(group); expect(setSuccessMessage).toHaveBeenCalledWith( 'Successfully renamed this group to "group".' @@ -313,11 +308,10 @@ describe('GroupLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.updateGroupName(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -330,15 +324,14 @@ describe('GroupLogic', () => { }); it('updates name', async () => { const onGroupSourcesSavedSpy = jest.spyOn(GroupLogic.actions, 'onGroupSourcesSaved'); - const promise = Promise.resolve(group); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(group)); GroupLogic.actions.saveGroupSources(); expect(http.post).toHaveBeenCalledWith('/api/workplace_search/groups/123/share', { body: JSON.stringify({ content_source_ids: sourceIds }), }); - await promise; + await nextTick(); expect(onGroupSourcesSavedSpy).toHaveBeenCalledWith(group); expect(setSuccessMessage).toHaveBeenCalledWith( 'Successfully updated shared content sources.' @@ -346,11 +339,10 @@ describe('GroupLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.saveGroupSources(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -362,15 +354,14 @@ describe('GroupLogic', () => { }); it('updates name', async () => { const onGroupUsersSavedSpy = jest.spyOn(GroupLogic.actions, 'onGroupUsersSaved'); - const promise = Promise.resolve(group); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(group)); GroupLogic.actions.saveGroupUsers(); expect(http.post).toHaveBeenCalledWith('/api/workplace_search/groups/123/assign', { body: JSON.stringify({ user_ids: userIds }), }); - await promise; + await nextTick(); expect(onGroupUsersSavedSpy).toHaveBeenCalledWith(group); expect(setSuccessMessage).toHaveBeenCalledWith( 'Successfully updated the users of this group.' @@ -378,11 +369,10 @@ describe('GroupLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.saveGroupUsers(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -397,8 +387,7 @@ describe('GroupLogic', () => { GroupLogic.actions, 'onGroupPrioritiesChanged' ); - const promise = Promise.resolve(group); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve(group)); GroupLogic.actions.saveGroupSourcePrioritization(); expect(http.put).toHaveBeenCalledWith('/api/workplace_search/groups/123/boosts', { @@ -410,7 +399,7 @@ describe('GroupLogic', () => { }), }); - await promise; + await nextTick(); expect(setSuccessMessage).toHaveBeenCalledWith( 'Successfully updated shared source prioritization.' ); @@ -418,11 +407,10 @@ describe('GroupLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.saveGroupSourcePrioritization(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.test.ts index 76352a6670650..6c9f912a98ce8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.test.ts @@ -4,12 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; import { DEFAULT_META } from '../../../shared/constants'; import { JSON_HEADER as headers } from '../../../../../common/constants'; @@ -22,7 +19,6 @@ import { GroupsLogic } from './groups_logic'; // We need to mock out the debounced functionality const TIMEOUT = 400; -const delay = () => new Promise((resolve) => setTimeout(resolve, TIMEOUT)); describe('GroupsLogic', () => { const { mount } = new LogicMounter(GroupsLogic); @@ -218,21 +214,19 @@ describe('GroupsLogic', () => { describe('initializeGroups', () => { it('calls API and sets values', async () => { const onInitializeGroupsSpy = jest.spyOn(GroupsLogic.actions, 'onInitializeGroups'); - const promise = Promise.resolve(groupsResponse); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(groupsResponse)); GroupsLogic.actions.initializeGroups(); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/groups'); - await promise; + await nextTick(); expect(onInitializeGroupsSpy).toHaveBeenCalledWith(groupsResponse); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); GroupsLogic.actions.initializeGroups(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -256,15 +250,22 @@ describe('GroupsLogic', () => { headers, }; + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + it('calls API and sets values', async () => { const setSearchResultsSpy = jest.spyOn(GroupsLogic.actions, 'setSearchResults'); - const promise = Promise.resolve(groups); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(groups)); GroupsLogic.actions.getSearchResults(); - await delay(); + jest.advanceTimersByTime(TIMEOUT); + await nextTick(); expect(http.post).toHaveBeenCalledWith('/api/workplace_search/groups/search', payload); - await promise; expect(setSearchResultsSpy).toHaveBeenCalledWith(groups); }); @@ -272,24 +273,22 @@ describe('GroupsLogic', () => { // Set active page to 2 to confirm resetting sends the `payload` value of 1 for the current page. GroupsLogic.actions.setActivePage(2); const setSearchResultsSpy = jest.spyOn(GroupsLogic.actions, 'setSearchResults'); - const promise = Promise.resolve(groups); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(groups)); GroupsLogic.actions.getSearchResults(true); // Account for `breakpoint` that debounces filter value. - await delay(); + jest.advanceTimersByTime(TIMEOUT); + await nextTick(); expect(http.post).toHaveBeenCalledWith('/api/workplace_search/groups/search', payload); - await promise; expect(setSearchResultsSpy).toHaveBeenCalledWith(groups); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); GroupsLogic.actions.getSearchResults(); - await expectedAsyncError(promise); - await delay(); + jest.advanceTimersByTime(TIMEOUT); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -298,21 +297,19 @@ describe('GroupsLogic', () => { describe('fetchGroupUsers', () => { it('calls API and sets values', async () => { const setGroupUsersSpy = jest.spyOn(GroupsLogic.actions, 'setGroupUsers'); - const promise = Promise.resolve(users); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(users)); GroupsLogic.actions.fetchGroupUsers('123'); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/groups/123/group_users'); - await promise; + await nextTick(); expect(setGroupUsersSpy).toHaveBeenCalledWith(users); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); GroupsLogic.actions.fetchGroupUsers('123'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -323,24 +320,22 @@ describe('GroupsLogic', () => { const GROUP_NAME = 'new group'; GroupsLogic.actions.setNewGroupName(GROUP_NAME); const setNewGroupSpy = jest.spyOn(GroupsLogic.actions, 'setNewGroup'); - const promise = Promise.resolve(groups[0]); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(groups[0])); GroupsLogic.actions.saveNewGroup(); expect(http.post).toHaveBeenCalledWith('/api/workplace_search/groups', { body: JSON.stringify({ group_name: GROUP_NAME }), headers, }); - await promise; + await nextTick(); expect(setNewGroupSpy).toHaveBeenCalledWith(groups[0]); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); GroupsLogic.actions.saveNewGroup(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.test.tsx new file mode 100644 index 0000000000000..4db5c60d5800d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setMockValues } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiSwitch } from '@elastic/eui'; + +import { PrivateSourcesTable } from './private_sources_table'; + +describe('PrivateSourcesTable', () => { + beforeEach(() => { + setMockValues({ hasPlatinumLicense: true, isEnabled: true }); + }); + + const props = { + sourceSection: { isEnabled: true, contentSources: [] }, + updateSource: jest.fn(), + updateEnabled: jest.fn(), + }; + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiSwitch)).toHaveLength(1); + }); + + it('handles switches clicks', () => { + const wrapper = shallow( + + ); + + const sectionSwitch = wrapper.find(EuiSwitch).first(); + const sourceSwitch = wrapper.find(EuiSwitch).last(); + + const event = { target: { value: true } }; + sectionSwitch.prop('onChange')(event as any); + sourceSwitch.prop('onChange')(event as any); + + expect(props.updateEnabled).toHaveBeenCalled(); + expect(props.updateSource).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx new file mode 100644 index 0000000000000..c767dfaba86f9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import classNames from 'classnames'; +import { useValues } from 'kea'; + +import { + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiText, + EuiTable, + EuiTableBody, + EuiTableHeader, + EuiTableHeaderCell, + EuiTableRow, + EuiTableRowCell, + EuiSpacer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { LicensingLogic } from '../../../../shared/licensing'; +import { SecurityLogic, PrivateSourceSection } from '../security_logic'; +import { + REMOTE_SOURCES_TOGGLE_TEXT, + REMOTE_SOURCES_TABLE_DESCRIPTION, + REMOTE_SOURCES_EMPTY_TABLE_TITLE, + STANDARD_SOURCES_TOGGLE_TEXT, + STANDARD_SOURCES_TABLE_DESCRIPTION, + STANDARD_SOURCES_EMPTY_TABLE_TITLE, + SOURCE, +} from '../../../constants'; + +interface PrivateSourcesTableProps { + sourceType: 'remote' | 'standard'; + sourceSection: PrivateSourceSection; + updateSource(sourceId: string, isEnabled: boolean): void; + updateEnabled(isEnabled: boolean): void; +} + +const REMOTE_SOURCES_EMPTY_TABLE_DESCRIPTION = ( + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesEmptyTable.enabledStrong', + { defaultMessage: 'enabled by default' } + )} + + ), + }} + /> +); + +const STANDARD_SOURCES_EMPTY_TABLE_DESCRIPTION = ( + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesEmptyTable.notEnabledStrong', + { defaultMessage: 'not enabled by default' } + )} + + ), + }} + /> +); + +export const PrivateSourcesTable: React.FC = ({ + sourceType, + sourceSection: { isEnabled: sectionEnabled, contentSources }, + updateSource, + updateEnabled, +}) => { + const { hasPlatinumLicense } = useValues(LicensingLogic); + const { isEnabled } = useValues(SecurityLogic); + + const isRemote = sourceType === 'remote'; + const hasSources = contentSources.length > 0; + const panelDisabled = !isEnabled || !hasPlatinumLicense; + const sectionDisabled = !sectionEnabled; + + const panelClass = classNames('euiPanel--outline euiPanel--noShadow', { + 'euiPanel--disabled': panelDisabled, + }); + + const tableClass = classNames({ 'euiTable--disabled': sectionDisabled }); + + const emptyState = ( + <> + + + + + {isRemote ? REMOTE_SOURCES_EMPTY_TABLE_TITLE : STANDARD_SOURCES_EMPTY_TABLE_TITLE} + + + + {isRemote + ? REMOTE_SOURCES_EMPTY_TABLE_DESCRIPTION + : STANDARD_SOURCES_EMPTY_TABLE_DESCRIPTION} + + + + ); + + const sectionHeading = ( + + + + updateEnabled(e.target.checked)} + disabled={!isEnabled || !hasPlatinumLicense} + showLabel={false} + label={`${sourceType} Sources Toggle`} + data-test-subj={`${sourceType}EnabledToggle`} + compressed + /> + + + +

{isRemote ? REMOTE_SOURCES_TOGGLE_TEXT : STANDARD_SOURCES_TOGGLE_TEXT}

+
+ + {isRemote ? REMOTE_SOURCES_TABLE_DESCRIPTION : STANDARD_SOURCES_TABLE_DESCRIPTION} + + {!hasSources && emptyState} +
+
+ ); + + const sourcesTable = ( + <> + + + + {SOURCE} + + + + {contentSources.map((source, i) => ( + + {source.name} + + updateSource(source.id, e.target.checked)} + showLabel={false} + label={`${source.name} Toggle`} + data-test-subj={`${sourceType}SourceToggle`} + compressed + /> + + + ))} + + + + ); + + return ( + + {sectionHeading} + {hasSources && sourcesTable} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/index.ts new file mode 100644 index 0000000000000..a2db1bbc15a15 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Security } from './security'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.test.tsx new file mode 100644 index 0000000000000..bca0d5edc32d6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.test.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setMockValues, setMockActions } from '../../../__mocks__'; +import { unmountHandler } from '../../../__mocks__/shallow_useeffect.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiSwitch, EuiConfirmModal } from '@elastic/eui'; +import { Loading } from '../../../shared/loading'; + +import { ViewContentHeader } from '../../components/shared/view_content_header'; +import { Security } from './security'; + +describe('Security', () => { + const initializeSourceRestrictions = jest.fn(); + const updatePrivateSourcesEnabled = jest.fn(); + const updateRemoteEnabled = jest.fn(); + const updateRemoteSource = jest.fn(); + const updateStandardEnabled = jest.fn(); + const updateStandardSource = jest.fn(); + const saveSourceRestrictions = jest.fn(); + const resetState = jest.fn(); + + const mockValues = { + isEnabled: true, + remote: { isEnabled: true, contentSources: [] }, + standard: { isEnabled: true, contentSources: [] }, + dataLoading: false, + unsavedChanges: false, + hasPlatinumLicense: true, + }; + + beforeEach(() => { + setMockValues(mockValues); + setMockActions({ + initializeSourceRestrictions, + updatePrivateSourcesEnabled, + updateRemoteEnabled, + updateRemoteSource, + updateStandardEnabled, + updateStandardSource, + saveSourceRestrictions, + resetState, + }); + }); + + it('renders on Basic license', () => { + setMockValues({ ...mockValues, hasPlatinumLicense: false }); + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + expect(wrapper.find(EuiSwitch).prop('disabled')).toEqual(true); + }); + + it('renders on Platinum license', () => { + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + expect(wrapper.find(EuiSwitch).prop('disabled')).toEqual(false); + }); + + it('returns Loading when loading', () => { + setMockValues({ ...mockValues, dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('handles window.onbeforeunload change', () => { + setMockValues({ ...mockValues, unsavedChanges: true }); + shallow(); + + expect(window.onbeforeunload!({} as any)).toEqual( + 'Your private sources settings have not been saved. Are you sure you want to leave?' + ); + }); + + it('handles window.onbeforeunload unmount', () => { + setMockValues({ ...mockValues, unsavedChanges: true }); + shallow(); + + unmountHandler(); + + expect(window.onbeforeunload).toEqual(null); + }); + + it('handles switch click', () => { + const wrapper = shallow(); + + const privateSourcesSwitch = wrapper.find(EuiSwitch); + const event = { target: { checked: true } }; + privateSourcesSwitch.prop('onChange')(event as any); + + expect(updatePrivateSourcesEnabled).toHaveBeenCalled(); + }); + + it('handles confirmModal submission', () => { + setMockValues({ ...mockValues, unsavedChanges: true }); + const wrapper = shallow(); + + const header = wrapper.find(ViewContentHeader).dive(); + header.find('[data-test-subj="SaveSettingsButton"]').prop('onClick')!({} as any); + const modal = wrapper.find(EuiConfirmModal); + modal.prop('onConfirm')!({} as any); + + expect(saveSourceRestrictions).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.tsx new file mode 100644 index 0000000000000..41df1a1acc515 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; + +import classNames from 'classnames'; +import { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiText, + EuiSpacer, + EuiPanel, + EuiConfirmModal, + EuiOverlayMask, +} from '@elastic/eui'; + +import { LicensingLogic } from '../../../shared/licensing'; +import { FlashMessages } from '../../../shared/flash_messages'; +import { LicenseCallout } from '../../components/shared/license_callout'; +import { Loading } from '../../../shared/loading'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; +import { SecurityLogic } from './security_logic'; + +import { PrivateSourcesTable } from './components/private_sources_table'; + +import { + SECURITY_UNSAVED_CHANGES_MESSAGE, + RESET_BUTTON, + SAVE_SETTINGS_BUTTON, + SAVE_CHANGES_BUTTON, + KEEP_EDITING_BUTTON, + PRIVATE_SOURCES, + PRIVATE_SOURCES_DESCRIPTION, + PRIVATE_SOURCES_TOGGLE_DESCRIPTION, + PRIVATE_PLATINUM_LICENSE_CALLOUT, + CONFIRM_CHANGES_TEXT, + PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT, +} from '../../constants'; + +export const Security: React.FC = () => { + const [confirmModalVisible, setConfirmModalVisibility] = useState(false); + + const hideConfirmModal = () => setConfirmModalVisibility(false); + const showConfirmModal = () => setConfirmModalVisibility(true); + + const { hasPlatinumLicense } = useValues(LicensingLogic); + + const { + initializeSourceRestrictions, + updatePrivateSourcesEnabled, + updateRemoteEnabled, + updateRemoteSource, + updateStandardEnabled, + updateStandardSource, + saveSourceRestrictions, + resetState, + } = useActions(SecurityLogic); + + const { isEnabled, remote, standard, dataLoading, unsavedChanges } = useValues(SecurityLogic); + + useEffect(() => { + initializeSourceRestrictions(); + }, []); + + useEffect(() => { + window.onbeforeunload = unsavedChanges ? () => SECURITY_UNSAVED_CHANGES_MESSAGE : null; + return () => { + window.onbeforeunload = null; + }; + }, [unsavedChanges]); + + if (dataLoading) return ; + + const panelClass = classNames('euiPanel--noShadow', { + 'euiPanel--disabled': !hasPlatinumLicense, + }); + + const savePrivateSources = () => { + saveSourceRestrictions(); + hideConfirmModal(); + }; + + const headerActions = ( + + + + {RESET_BUTTON} + + + + + {SAVE_SETTINGS_BUTTON} + + + + ); + + const header = ( + <> + + + + ); + + const allSourcesToggle = ( + + + + updatePrivateSourcesEnabled(e.target.checked)} + disabled={!hasPlatinumLicense} + showLabel={false} + label="Private Sources Toggle" + data-test-subj="PrivateSourcesToggle" + /> + + + +

{PRIVATE_SOURCES_TOGGLE_DESCRIPTION}

+
+
+
+
+ ); + + const platinumLicenseCallout = ( + <> + + + + ); + + const sourceTables = ( + <> + + + + + + ); + + const confirmModal = ( + + + {PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT} + + + ); + + return ( + <> + + {header} + {allSourcesToggle} + {!hasPlatinumLicense && platinumLicenseCallout} + {sourceTables} + {confirmModalVisible && confirmModal} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.test.ts new file mode 100644 index 0000000000000..abb1308081f0c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.test.ts @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LogicMounter } from '../../../__mocks__/kea.mock'; +import { mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; +import { SecurityLogic } from './security_logic'; +import { nextTick } from '@kbn/test/jest'; + +describe('SecurityLogic', () => { + const { http } = mockHttpValues; + const { flashAPIErrors } = mockFlashMessageHelpers; + const { mount } = new LogicMounter(SecurityLogic); + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + const defaultValues = { + dataLoading: true, + cachedServerState: {}, + isEnabled: false, + remote: {}, + standard: {}, + unsavedChanges: true, + }; + + const serverProps = { + isEnabled: true, + remote: { + isEnabled: true, + contentSources: [{ id: 'gmail', name: 'Gmail', isEnabled: true }], + }, + standard: { + isEnabled: true, + contentSources: [{ id: 'one_drive', name: 'OneDrive', isEnabled: true }], + }, + }; + + it('has expected default values', () => { + expect(SecurityLogic.values).toEqual(defaultValues); + }); + + describe('actions', () => { + it('setServerProps', () => { + SecurityLogic.actions.setServerProps(serverProps); + + expect(SecurityLogic.values.isEnabled).toEqual(true); + }); + + it('setSourceRestrictionsUpdated', () => { + SecurityLogic.actions.setSourceRestrictionsUpdated(serverProps); + + expect(SecurityLogic.values.isEnabled).toEqual(true); + }); + + it('updatePrivateSourcesEnabled', () => { + SecurityLogic.actions.updatePrivateSourcesEnabled(false); + + expect(SecurityLogic.values.isEnabled).toEqual(false); + }); + + it('updateRemoteEnabled', () => { + SecurityLogic.actions.updateRemoteEnabled(false); + + expect(SecurityLogic.values.remote.isEnabled).toEqual(false); + }); + + it('updateStandardEnabled', () => { + SecurityLogic.actions.updateStandardEnabled(false); + + expect(SecurityLogic.values.standard.isEnabled).toEqual(false); + }); + + it('updateRemoteSource', () => { + SecurityLogic.actions.setServerProps(serverProps); + SecurityLogic.actions.updateRemoteSource('gmail', false); + + expect(SecurityLogic.values.remote.contentSources[0].isEnabled).toEqual(false); + }); + + it('updateStandardSource', () => { + SecurityLogic.actions.setServerProps(serverProps); + SecurityLogic.actions.updateStandardSource('one_drive', false); + + expect(SecurityLogic.values.standard.contentSources[0].isEnabled).toEqual(false); + }); + }); + + describe('selectors', () => { + describe('unsavedChanges', () => { + it('returns true while loading', () => { + expect(SecurityLogic.values.unsavedChanges).toEqual(true); + }); + + it('returns false after loading', () => { + SecurityLogic.actions.setServerProps(serverProps); + + expect(SecurityLogic.values.unsavedChanges).toEqual(false); + }); + }); + }); + + describe('listeners', () => { + describe('initializeSourceRestrictions', () => { + it('calls API and sets values', async () => { + const setServerPropsSpy = jest.spyOn(SecurityLogic.actions, 'setServerProps'); + http.get.mockReturnValue(Promise.resolve(serverProps)); + SecurityLogic.actions.initializeSourceRestrictions(); + + expect(http.get).toHaveBeenCalledWith( + '/api/workplace_search/org/security/source_restrictions' + ); + await nextTick(); + expect(setServerPropsSpy).toHaveBeenCalledWith(serverProps); + }); + + it('handles error', async () => { + http.get.mockReturnValue(Promise.reject('this is an error')); + + SecurityLogic.actions.initializeSourceRestrictions(); + try { + await nextTick(); + } catch { + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + } + }); + }); + + describe('saveSourceRestrictions', () => { + it('calls API and sets values', async () => { + http.patch.mockReturnValue(Promise.resolve(serverProps)); + SecurityLogic.actions.setSourceRestrictionsUpdated(serverProps); + SecurityLogic.actions.saveSourceRestrictions(); + + expect(http.patch).toHaveBeenCalledWith( + '/api/workplace_search/org/security/source_restrictions', + { + body: JSON.stringify(serverProps), + } + ); + }); + + it('handles error', async () => { + http.patch.mockReturnValue(Promise.reject('this is an error')); + + SecurityLogic.actions.saveSourceRestrictions(); + try { + await nextTick(); + } catch { + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + } + }); + }); + + describe('resetState', () => { + it('calls API and sets values', async () => { + SecurityLogic.actions.setServerProps(serverProps); + SecurityLogic.actions.updatePrivateSourcesEnabled(false); + SecurityLogic.actions.resetState(); + + expect(SecurityLogic.values.isEnabled).toEqual(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.ts new file mode 100644 index 0000000000000..df843b330d411 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { cloneDeep } from 'lodash'; +import { isEqual } from 'lodash'; + +import { kea, MakeLogicType } from 'kea'; + +import { + clearFlashMessages, + setSuccessMessage, + flashAPIErrors, +} from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; +import { AppLogic } from '../../app_logic'; + +import { SOURCE_RESTRICTIONS_SUCCESS_MESSAGE } from '../../constants'; + +export interface PrivateSource { + id: string; + name: string; + isEnabled: boolean; +} + +export interface PrivateSourceSection { + isEnabled: boolean; + contentSources: PrivateSource[]; +} + +export interface SecurityServerProps { + isEnabled: boolean; + remote: PrivateSourceSection; + standard: PrivateSourceSection; +} + +interface SecurityValues extends SecurityServerProps { + dataLoading: boolean; + unsavedChanges: boolean; + cachedServerState: SecurityServerProps; +} + +interface SecurityActions { + setServerProps(serverProps: SecurityServerProps): SecurityServerProps; + setSourceRestrictionsUpdated(serverProps: SecurityServerProps): SecurityServerProps; + initializeSourceRestrictions(): void; + saveSourceRestrictions(): void; + updatePrivateSourcesEnabled(isEnabled: boolean): { isEnabled: boolean }; + updateRemoteEnabled(isEnabled: boolean): { isEnabled: boolean }; + updateRemoteSource( + sourceId: string, + isEnabled: boolean + ): { sourceId: string; isEnabled: boolean }; + updateStandardEnabled(isEnabled: boolean): { isEnabled: boolean }; + updateStandardSource( + sourceId: string, + isEnabled: boolean + ): { sourceId: string; isEnabled: boolean }; + resetState(): void; +} + +const route = '/api/workplace_search/org/security/source_restrictions'; + +export const SecurityLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'security_logic'], + actions: { + setServerProps: (serverProps: SecurityServerProps) => serverProps, + setSourceRestrictionsUpdated: (serverProps: SecurityServerProps) => serverProps, + initializeSourceRestrictions: () => true, + saveSourceRestrictions: () => null, + updatePrivateSourcesEnabled: (isEnabled: boolean) => ({ isEnabled }), + updateRemoteEnabled: (isEnabled: boolean) => ({ isEnabled }), + updateRemoteSource: (sourceId: string, isEnabled: boolean) => ({ sourceId, isEnabled }), + updateStandardEnabled: (isEnabled: boolean) => ({ isEnabled }), + updateStandardSource: (sourceId: string, isEnabled: boolean) => ({ sourceId, isEnabled }), + resetState: () => null, + }, + reducers: { + dataLoading: [ + true, + { + setServerProps: () => false, + }, + ], + cachedServerState: [ + {} as SecurityServerProps, + { + setServerProps: (_, serverProps) => cloneDeep(serverProps), + setSourceRestrictionsUpdated: (_, serverProps) => cloneDeep(serverProps), + }, + ], + isEnabled: [ + false, + { + setServerProps: (_, { isEnabled }) => isEnabled, + setSourceRestrictionsUpdated: (_, { isEnabled }) => isEnabled, + updatePrivateSourcesEnabled: (_, { isEnabled }) => isEnabled, + }, + ], + remote: [ + {} as PrivateSourceSection, + { + setServerProps: (_, { remote }) => remote, + setSourceRestrictionsUpdated: (_, { remote }) => remote, + updateRemoteEnabled: (state, { isEnabled }) => ({ ...state, isEnabled }), + updateRemoteSource: (state, { sourceId, isEnabled }) => + updateSourceEnabled(state, sourceId, isEnabled), + }, + ], + standard: [ + {} as PrivateSourceSection, + { + setServerProps: (_, { standard }) => standard, + setSourceRestrictionsUpdated: (_, { standard }) => standard, + updateStandardEnabled: (state, { isEnabled }) => ({ ...state, isEnabled }), + updateStandardSource: (state, { sourceId, isEnabled }) => + updateSourceEnabled(state, sourceId, isEnabled), + }, + ], + }, + selectors: ({ selectors }) => ({ + unsavedChanges: [ + () => [ + selectors.cachedServerState, + selectors.isEnabled, + selectors.remote, + selectors.standard, + ], + (cached, isEnabled, remote, standard) => + cached.isEnabled !== isEnabled || + !isEqual(cached.remote, remote) || + !isEqual(cached.standard, standard), + ], + }), + listeners: ({ actions, values }) => ({ + initializeSourceRestrictions: async () => { + const { http } = HttpLogic.values; + + try { + const response = await http.get(route); + actions.setServerProps(response); + } catch (e) { + flashAPIErrors(e); + } + }, + saveSourceRestrictions: async () => { + const { isEnabled, remote, standard } = values; + const serverData = { isEnabled, remote, standard }; + const body = JSON.stringify(serverData); + const { http } = HttpLogic.values; + + try { + const response = await http.patch(route, { body }); + actions.setSourceRestrictionsUpdated(response); + setSuccessMessage(SOURCE_RESTRICTIONS_SUCCESS_MESSAGE); + AppLogic.actions.setSourceRestriction(isEnabled); + } catch (e) { + flashAPIErrors(e); + } + }, + resetState: () => { + actions.setServerProps(cloneDeep(values.cachedServerState)); + clearFlashMessages(); + }, + }), +}); + +const updateSourceEnabled = ( + section: PrivateSourceSection, + id: string, + isEnabled: boolean +): PrivateSourceSection => { + const updatedSection = { ...section }; + const sources = updatedSection.contentSources; + const sourceIndex = sources.findIndex((source) => source.id === id); + updatedSection.contentSources[sourceIndex] = { ...sources[sourceIndex], isEnabled }; + + return updatedSection; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts index aaeae08d552d4..e21b62b500067 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts @@ -6,12 +6,9 @@ import { LogicMounter } from '../../../__mocks__/kea.mock'; -import { - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, - mockKibanaValues, -} from '../../../__mocks__'; +import { mockFlashMessageHelpers, mockHttpValues, mockKibanaValues } from '../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; import { configuredSources, oauthApplication } from '../../__mocks__/content_sources.mock'; @@ -89,20 +86,18 @@ describe('SettingsLogic', () => { describe('initializeSettings', () => { it('calls API and sets values', async () => { const setServerPropsSpy = jest.spyOn(SettingsLogic.actions, 'setServerProps'); - const promise = Promise.resolve(configuredSources); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(configuredSources)); SettingsLogic.actions.initializeSettings(); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/settings'); - await promise; + await nextTick(); expect(setServerPropsSpy).toHaveBeenCalledWith(configuredSources); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); SettingsLogic.actions.initializeSettings(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -114,20 +109,18 @@ describe('SettingsLogic', () => { SettingsLogic.actions, 'onInitializeConnectors' ); - const promise = Promise.resolve(serverProps); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(serverProps)); SettingsLogic.actions.initializeConnectors(); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/settings/connectors'); - await promise; + await nextTick(); expect(onInitializeConnectorsSpy).toHaveBeenCalledWith(serverProps); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); SettingsLogic.actions.initializeConnectors(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -138,25 +131,23 @@ describe('SettingsLogic', () => { const NAME = 'updated name'; SettingsLogic.actions.onOrgNameInputChange(NAME); const setUpdatedNameSpy = jest.spyOn(SettingsLogic.actions, 'setUpdatedName'); - const promise = Promise.resolve({ organizationName: NAME }); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve({ organizationName: NAME })); SettingsLogic.actions.updateOrgName(); expect(http.put).toHaveBeenCalledWith('/api/workplace_search/org/settings/customize', { body: JSON.stringify({ name: NAME }), }); - await promise; + await nextTick(); expect(setSuccessMessage).toHaveBeenCalledWith(ORG_UPDATED_MESSAGE); expect(setUpdatedNameSpy).toHaveBeenCalledWith({ organizationName: NAME }); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('this is an error')); SettingsLogic.actions.updateOrgName(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); }); @@ -168,8 +159,7 @@ describe('SettingsLogic', () => { SettingsLogic.actions, 'setUpdatedOauthApplication' ); - const promise = Promise.resolve({ oauthApplication }); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve({ oauthApplication })); SettingsLogic.actions.setOauthApplication(oauthApplication); SettingsLogic.actions.updateOauthApplication(); @@ -183,16 +173,15 @@ describe('SettingsLogic', () => { }), } ); - await promise; + await nextTick(); expect(setUpdatedOauthApplicationSpy).toHaveBeenCalledWith({ oauthApplication }); expect(setSuccessMessage).toHaveBeenCalledWith(OAUTH_APP_UPDATED_MESSAGE); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('this is an error')); SettingsLogic.actions.updateOauthApplication(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -203,20 +192,18 @@ describe('SettingsLogic', () => { const NAME = 'baz'; it('calls API and sets values', async () => { - const promise = Promise.resolve({}); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.resolve({})); SettingsLogic.actions.deleteSourceConfig(SERVICE_TYPE, NAME); - await promise; + await nextTick(); expect(navigateToUrl).toHaveBeenCalledWith('/settings/connectors'); expect(setQueuedSuccessMessage).toHaveBeenCalled(); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.reject('this is an error')); SettingsLogic.actions.deleteSourceConfig(SERVICE_TYPE, NAME); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts index 99445108b315a..f2792be8e6535 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts @@ -10,10 +10,12 @@ import { registerOverviewRoute } from './overview'; import { registerGroupsRoutes } from './groups'; import { registerSourcesRoutes } from './sources'; import { registerSettingsRoutes } from './settings'; +import { registerSecurityRoutes } from './security'; export const registerWorkplaceSearchRoutes = (dependencies: RouteDependencies) => { registerOverviewRoute(dependencies); registerGroupsRoutes(dependencies); registerSourcesRoutes(dependencies); registerSettingsRoutes(dependencies); + registerSecurityRoutes(dependencies); }; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.test.ts new file mode 100644 index 0000000000000..12f84278e9ead --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; + +import { registerSecurityRoute, registerSecuritySourceRestrictionsRoute } from './security'; + +describe('security routes', () => { + describe('GET /api/workplace_search/org/security', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/org/security', + }); + + registerSecurityRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + mockRouter.callRoute({}); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/security', + }); + }); + }); + + describe('GET /api/workplace_search/org/security/source_restrictions', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/org/security/source_restrictions', + payload: 'body', + }); + + registerSecuritySourceRestrictionsRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + mockRouter.callRoute({}); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/security/source_restrictions', + }); + }); + }); + + describe('PATCH /api/workplace_search/org/security/source_restrictions', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + + mockRouter = new MockRouter({ + method: 'patch', + path: '/api/workplace_search/org/security/source_restrictions', + payload: 'body', + }); + + registerSecuritySourceRestrictionsRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/security/source_restrictions', + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { + body: { + isEnabled: true, + remote: { + isEnabled: true, + contentSources: [{ id: 'gmail', name: 'Gmail', isEnabled: true }], + }, + standard: { + isEnabled: false, + contentSources: [{ id: 'dropbox', name: 'Dropbox', isEnabled: false }], + }, + }, + }; + mockRouter.shouldValidate(request); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.ts new file mode 100644 index 0000000000000..0aa218dfc2883 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +export function registerSecurityRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/org/security', + validate: false, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/security', + }) + ); +} + +export function registerSecuritySourceRestrictionsRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/org/security/source_restrictions', + validate: false, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/security/source_restrictions', + }) + ); + + router.patch( + { + path: '/api/workplace_search/org/security/source_restrictions', + validate: { + body: schema.object({ + isEnabled: schema.boolean(), + remote: schema.object({ + isEnabled: schema.boolean(), + contentSources: schema.arrayOf( + schema.object({ + isEnabled: schema.boolean(), + id: schema.string(), + name: schema.string(), + }) + ), + }), + standard: schema.object({ + isEnabled: schema.boolean(), + contentSources: schema.arrayOf( + schema.object({ + isEnabled: schema.boolean(), + id: schema.string(), + name: schema.string(), + }) + ), + }), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/security/source_restrictions', + }) + ); +} + +export const registerSecurityRoutes = (dependencies: RouteDependencies) => { + registerSecurityRoute(dependencies); + registerSecuritySourceRestrictionsRoute(dependencies); +}; diff --git a/x-pack/plugins/enterprise_search/tsconfig.json b/x-pack/plugins/enterprise_search/tsconfig.json new file mode 100644 index 0000000000000..6b4c50770b49f --- /dev/null +++ b/x-pack/plugins/enterprise_search/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "../../../typings/**/*", + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/charts/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../cloud/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, + { "path": "../spaces/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts b/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts index efc25cc2efb5d..4f17a2b88670a 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts @@ -11,12 +11,10 @@ import { appContextService, licenseService } from '../../'; const PRODUCTION_REGISTRY_URL_CDN = 'https://epr.elastic.co'; // const STAGING_REGISTRY_URL_CDN = 'https://epr-staging.elastic.co'; -// const EXPERIMENTAL_REGISTRY_URL_CDN = 'https://epr-experimental.elastic.co/'; const SNAPSHOT_REGISTRY_URL_CDN = 'https://epr-snapshot.elastic.co'; // const PRODUCTION_REGISTRY_URL_NO_CDN = 'https://epr.ea-web.elastic.dev'; // const STAGING_REGISTRY_URL_NO_CDN = 'https://epr-staging.ea-web.elastic.dev'; -// const EXPERIMENTAL_REGISTRY_URL_NO_CDN = 'https://epr-experimental.ea-web.elastic.dev/'; // const SNAPSHOT_REGISTRY_URL_NO_CDN = 'https://epr-snapshot.ea-web.elastic.dev'; const getDefaultRegistryUrl = (): string => { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index 64b654b030236..d9256ec916ec8 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -251,6 +251,7 @@ export const setup = async (arg?: { appServicesContext: Partial exists('timelineHotPhaseRolloverToolTip'), hasHotPhase: () => exists('ilmTimelineHotPhase'), hasWarmPhase: () => exists('ilmTimelineWarmPhase'), hasColdPhase: () => exists('ilmTimelineColdPhase'), diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index bb96e8b4df239..05793a4bed581 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -843,5 +843,13 @@ describe('', () => { expect(actions.timeline.hasColdPhase()).toBe(true); expect(actions.timeline.hasDeletePhase()).toBe(true); }); + + test('show and hide rollover indicator on timeline', async () => { + const { actions } = testBed; + expect(actions.timeline.hasRolloverIndicator()).toBe(true); + await actions.hot.toggleDefaultRollover(false); + await actions.hot.toggleRollover(false); + expect(actions.timeline.hasRolloverIndicator()).toBe(false); + }); }); }); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx index fb7c9a80acba0..02de47f8c56ef 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx @@ -16,6 +16,7 @@ import { EuiTextColor, EuiSwitch, EuiIconTip, + EuiIcon, } from '@elastic/eui'; import { useFormData, UseField, SelectField, NumericField } from '../../../../../../shared_imports'; @@ -80,6 +81,10 @@ export const HotPhase: FunctionComponent = () => {

+ +   + {i18nTexts.editPolicy.rolloverOffsetsHotPhaseTiming} + path={isUsingDefaultRolloverPath}> {(field) => ( <> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/components/index.ts new file mode 100644 index 0000000000000..1c9d5e1abc316 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { TimelinePhaseText } from './timeline_phase_text'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/components/timeline_phase_text.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/components/timeline_phase_text.tsx new file mode 100644 index 0000000000000..a44e0f2407c52 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/components/timeline_phase_text.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent, ReactNode } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; + +export const TimelinePhaseText: FunctionComponent<{ + phaseName: ReactNode | string; + durationInPhase?: ReactNode | string; +}> = ({ phaseName, durationInPhase }) => ( + + + + {phaseName} + + + + {typeof durationInPhase === 'string' ? ( + {durationInPhase} + ) : ( + durationInPhase + )} + + +); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/index.ts index 4664429db37d7..7bcaa6584edf0 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/index.ts @@ -3,4 +3,4 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -export { Timeline } from './timeline'; +export { Timeline } from './timeline.container'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.container.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.container.tsx new file mode 100644 index 0000000000000..75f53fcb25091 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.container.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; + +import { useFormData } from '../../../../../shared_imports'; + +import { formDataToAbsoluteTimings } from '../../lib'; + +import { useConfigurationIssues } from '../../form'; + +import { FormInternal } from '../../types'; + +import { Timeline as ViewComponent } from './timeline'; + +export const Timeline: FunctionComponent = () => { + const [formData] = useFormData(); + const timings = formDataToAbsoluteTimings(formData); + const { isUsingRollover } = useConfigurationIssues(); + return ( + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.scss index 452221a29a991..7d65d2cd6b212 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.scss +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.scss @@ -84,4 +84,8 @@ $ilmDeletePhaseColor: shadeOrTint($euiColorVis5, 40%, 40%); background-color: $euiColorVis1; } } + + &__rolloverIcon { + display: inline-block; + } } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx index 40bab9c676de2..2e2db88e1384d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import React, { FunctionComponent, useMemo } from 'react'; +import React, { FunctionComponent, memo } from 'react'; import { - EuiText, EuiIcon, EuiIconProps, EuiFlexGroup, @@ -16,18 +15,19 @@ import { } from '@elastic/eui'; import { PhasesExceptDelete } from '../../../../../../common/types'; -import { useFormData } from '../../../../../shared_imports'; - -import { FormInternal } from '../../types'; import { - calculateRelativeTimingMs, + calculateRelativeFromAbsoluteMilliseconds, normalizeTimingsToHumanReadable, PhaseAgeInMilliseconds, + AbsoluteTimings, } from '../../lib'; import './timeline.scss'; import { InfinityIconSvg } from './infinity_icon.svg'; +import { TimelinePhaseText } from './components'; + +const exists = (v: unknown) => v != null; const InfinityIcon: FunctionComponent> = (props) => ( @@ -56,6 +56,13 @@ const i18nTexts = { hotPhase: i18n.translate('xpack.indexLifecycleMgmt.timeline.hotPhaseSectionTitle', { defaultMessage: 'Hot phase', }), + rolloverTooltip: i18n.translate( + 'xpack.indexLifecycleMgmt.timeline.hotPhaseRolloverToolTipContent', + { + defaultMessage: + 'How long it takes to reach the rollover criteria in the hot phase can vary. Data moves to the next phase when the time since rollover reaches the minimum age.', + } + ), warmPhase: i18n.translate('xpack.indexLifecycleMgmt.timeline.warmPhaseSectionTitle', { defaultMessage: 'Warm phase', }), @@ -88,121 +95,136 @@ const calculateWidths = (inputs: PhaseAgeInMilliseconds) => { }; }; -const TimelinePhaseText: FunctionComponent<{ - phaseName: string; - durationInPhase?: React.ReactNode | string; -}> = ({ phaseName, durationInPhase }) => ( - - - - {phaseName} - - - - {typeof durationInPhase === 'string' ? ( - {durationInPhase} - ) : ( - durationInPhase - )} - - -); - -export const Timeline: FunctionComponent = () => { - const [formData] = useFormData(); - - const phaseTimingInMs = useMemo(() => { - return calculateRelativeTimingMs(formData); - }, [formData]); +interface Props { + hasDeletePhase: boolean; + /** + * For now we assume the hot phase does not have a min age + */ + hotPhaseMinAge: undefined; + isUsingRollover: boolean; + warmPhaseMinAge?: string; + coldPhaseMinAge?: string; + deletePhaseMinAge?: string; +} - const humanReadableTimings = useMemo(() => normalizeTimingsToHumanReadable(phaseTimingInMs), [ - phaseTimingInMs, - ]); - - const widths = calculateWidths(phaseTimingInMs); - - const getDurationInPhaseContent = (phase: PhasesExceptDelete): string | React.ReactNode => - phaseTimingInMs.phases[phase] === Infinity ? ( - - ) : ( - humanReadableTimings[phase] - ); - - return ( - - - -

- {i18n.translate('xpack.indexLifecycleMgmt.timeline.title', { - defaultMessage: 'Policy Timeline', - })} -

-
-
- -
{ - if (el) { - el.style.setProperty('--ilm-timeline-hot-phase-width', widths.hot); - el.style.setProperty('--ilm-timeline-warm-phase-width', widths.warm ?? null); - el.style.setProperty('--ilm-timeline-cold-phase-width', widths.cold ?? null); - } - }} - > - - -
- {/* These are the actual color bars for the timeline */} -
-
- -
- {formData._meta?.warm.enabled && ( +/** + * Display a timeline given ILM policy phase information. This component is re-usable and memo-ized + * and should not rely directly on any application-specific context. + */ +export const Timeline: FunctionComponent = memo( + ({ hasDeletePhase, isUsingRollover, ...phasesMinAge }) => { + const absoluteTimings: AbsoluteTimings = { + hot: { min_age: phasesMinAge.hotPhaseMinAge }, + warm: phasesMinAge.warmPhaseMinAge ? { min_age: phasesMinAge.warmPhaseMinAge } : undefined, + cold: phasesMinAge.coldPhaseMinAge ? { min_age: phasesMinAge.coldPhaseMinAge } : undefined, + delete: phasesMinAge.deletePhaseMinAge + ? { min_age: phasesMinAge.deletePhaseMinAge } + : undefined, + }; + + const phaseAgeInMilliseconds = calculateRelativeFromAbsoluteMilliseconds(absoluteTimings); + const humanReadableTimings = normalizeTimingsToHumanReadable(phaseAgeInMilliseconds); + + const widths = calculateWidths(phaseAgeInMilliseconds); + + const getDurationInPhaseContent = (phase: PhasesExceptDelete): string | React.ReactNode => + phaseAgeInMilliseconds.phases[phase] === Infinity ? ( + + ) : ( + humanReadableTimings[phase] + ); + + return ( + + + +

+ {i18n.translate('xpack.indexLifecycleMgmt.timeline.title', { + defaultMessage: 'Policy Timeline', + })} +

+
+
+ +
{ + if (el) { + el.style.setProperty('--ilm-timeline-hot-phase-width', widths.hot); + el.style.setProperty('--ilm-timeline-warm-phase-width', widths.warm ?? null); + el.style.setProperty('--ilm-timeline-cold-phase-width', widths.cold ?? null); + } + }} + > + + +
+ {/* These are the actual color bars for the timeline */}
-
+
+ {i18nTexts.hotPhase} +   +
+ +
+ + ) : ( + i18nTexts.hotPhase + ) + } + durationInPhase={getDurationInPhaseContent('hot')} />
- )} - {formData._meta?.cold.enabled && ( + {exists(phaseAgeInMilliseconds.phases.warm) && ( +
+
+ +
+ )} + {exists(phaseAgeInMilliseconds.phases.cold) && ( +
+
+ +
+ )} +
+ + {hasDeletePhase && ( +
-
- +
- )} -
-
- {formData._meta?.delete.enabled && ( - -
- -
-
- )} - -
- - - ); -}; + + )} + +
+ + + ); + } +); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts index 71085a6d7a2b8..cf8c92b8333d0 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts @@ -11,6 +11,13 @@ export const i18nTexts = { shrinkLabel: i18n.translate('xpack.indexLifecycleMgmt.shrink.indexFieldLabel', { defaultMessage: 'Shrink index', }), + rolloverOffsetsHotPhaseTiming: i18n.translate( + 'xpack.indexLifecycleMgmt.rollover.rolloverOffsetsPhaseTimingDescription', + { + defaultMessage: + 'How long it takes to reach the rollover criteria in the hot phase can vary. Data moves to the next phase when the time since rollover reaches the minimum age.', + } + ), searchableSnapshotInHotPhase: { searchableSnapshotDisallowed: { calloutTitle: i18n.translate( diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.test.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.test.ts index 28910871fa33b..405de2b55a2f7 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.test.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.test.ts @@ -4,13 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { flow } from 'fp-ts/function'; import { deserializer } from '../form'; import { + formDataToAbsoluteTimings, + calculateRelativeFromAbsoluteMilliseconds, absoluteTimingToRelativeTiming, - calculateRelativeTimingMs, } from './absolute_timing_to_relative_timing'; +export const calculateRelativeTimingMs = flow( + formDataToAbsoluteTimings, + calculateRelativeFromAbsoluteMilliseconds +); + describe('Conversion of absolute policy timing to relative timing', () => { describe('calculateRelativeTimingMs', () => { describe('policy that never deletes data (keep forever)', () => { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts index 2f37608b2d7ae..a44863b2f1ce2 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts @@ -14,16 +14,21 @@ * * This code converts the absolute timings to _relative_ timings of the form: 30 days in hot phase, * 40 days in warm phase then forever in cold phase. + * + * All functions exported from this file can be viewed as utilities for working with form data and + * other defined interfaces to calculate the relative amount of time data will spend in a phase. */ import moment from 'moment'; -import { flow } from 'fp-ts/lib/function'; import { i18n } from '@kbn/i18n'; +import { flow } from 'fp-ts/function'; import { splitSizeAndUnits } from '../../../lib/policies'; import { FormInternal } from '../types'; +/* -===- Private functions and types -===- */ + type MinAgePhase = 'warm' | 'cold' | 'delete'; type Phase = 'hot' | MinAgePhase; @@ -43,7 +48,34 @@ const i18nTexts = { }), }; -interface AbsoluteTimings { +const phaseOrder: Phase[] = ['hot', 'warm', 'cold', 'delete']; + +const getMinAge = (phase: MinAgePhase, formData: FormInternal) => ({ + min_age: formData.phases?.[phase]?.min_age + ? formData.phases[phase]!.min_age! + formData._meta[phase].minAgeUnit + : '0ms', +}); + +/** + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math + * for all date math values. ILM policies also support "micros" and "nanos". + */ +const getPhaseMinAgeInMilliseconds = (phase: { min_age: string }): number => { + let milliseconds: number; + const { units, size } = splitSizeAndUnits(phase.min_age); + if (units === 'micros') { + milliseconds = parseInt(size, 10) / 1e3; + } else if (units === 'nanos') { + milliseconds = parseInt(size, 10) / 1e6; + } else { + milliseconds = moment.duration(size, units as any).asMilliseconds(); + } + return milliseconds; +}; + +/* -===- Public functions and types -===- */ + +export interface AbsoluteTimings { hot: { min_age: undefined; }; @@ -67,16 +99,7 @@ export interface PhaseAgeInMilliseconds { }; } -const phaseOrder: Phase[] = ['hot', 'warm', 'cold', 'delete']; - -const getMinAge = (phase: MinAgePhase, formData: FormInternal) => ({ - min_age: - formData.phases && formData.phases[phase]?.min_age - ? formData.phases[phase]!.min_age! + formData._meta[phase].minAgeUnit - : '0ms', -}); - -const formDataToAbsoluteTimings = (formData: FormInternal): AbsoluteTimings => { +export const formDataToAbsoluteTimings = (formData: FormInternal): AbsoluteTimings => { const { _meta } = formData; if (!_meta) { return { hot: { min_age: undefined } }; @@ -89,28 +112,13 @@ const formDataToAbsoluteTimings = (formData: FormInternal): AbsoluteTimings => { }; }; -/** - * See https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math - * for all date math values. ILM policies also support "micros" and "nanos". - */ -const getPhaseMinAgeInMilliseconds = (phase: { min_age: string }): number => { - let milliseconds: number; - const { units, size } = splitSizeAndUnits(phase.min_age); - if (units === 'micros') { - milliseconds = parseInt(size, 10) / 1e3; - } else if (units === 'nanos') { - milliseconds = parseInt(size, 10) / 1e6; - } else { - milliseconds = moment.duration(size, units as any).asMilliseconds(); - } - return milliseconds; -}; - /** * Given a set of phase minimum age absolute timings, like hot phase 0ms and warm phase 3d, work out * the number of milliseconds data will reside in phase. */ -const calculateMilliseconds = (inputs: AbsoluteTimings): PhaseAgeInMilliseconds => { +export const calculateRelativeFromAbsoluteMilliseconds = ( + inputs: AbsoluteTimings +): PhaseAgeInMilliseconds => { return phaseOrder.reduce( (acc, phaseName, idx) => { // Delete does not have an age associated with it @@ -152,6 +160,8 @@ const calculateMilliseconds = (inputs: AbsoluteTimings): PhaseAgeInMilliseconds ); }; +export type RelativePhaseTimingInMs = ReturnType; + const millisecondsToDays = (milliseconds?: number): string | undefined => { if (milliseconds == null) { return; @@ -177,10 +187,12 @@ export const normalizeTimingsToHumanReadable = ({ }; }; -export const calculateRelativeTimingMs = flow(formDataToAbsoluteTimings, calculateMilliseconds); - +/** + * Given {@link FormInternal}, extract the min_age values for each phase and calculate + * human readable strings for communicating how long data will remain in a phase. + */ export const absoluteTimingToRelativeTiming = flow( formDataToAbsoluteTimings, - calculateMilliseconds, + calculateRelativeFromAbsoluteMilliseconds, normalizeTimingsToHumanReadable ); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts index 9593fcc810a6f..a9372c99a72fc 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts @@ -6,7 +6,10 @@ export { absoluteTimingToRelativeTiming, - calculateRelativeTimingMs, + calculateRelativeFromAbsoluteMilliseconds, normalizeTimingsToHumanReadable, + formDataToAbsoluteTimings, + AbsoluteTimings, PhaseAgeInMilliseconds, + RelativePhaseTimingInMs, } from './absolute_timing_to_relative_timing'; diff --git a/x-pack/plugins/infra/kibana.json b/x-pack/plugins/infra/kibana.json index e84767f4931ca..d1fa83793d1dd 100644 --- a/x-pack/plugins/infra/kibana.json +++ b/x-pack/plugins/infra/kibana.json @@ -6,7 +6,7 @@ "features", "usageCollection", "spaces", - + "embeddable", "data", "dataEnhanced", "visTypeTimeseries", diff --git a/x-pack/plugins/infra/public/components/log_stream/index.ts b/x-pack/plugins/infra/public/components/log_stream/index.ts new file mode 100644 index 0000000000000..6abb292f919d9 --- /dev/null +++ b/x-pack/plugins/infra/public/components/log_stream/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './log_stream'; diff --git a/x-pack/plugins/infra/public/components/log_stream/lazy_log_stream_wrapper.tsx b/x-pack/plugins/infra/public/components/log_stream/lazy_log_stream_wrapper.tsx index 65433aab15716..13eb6431f97a3 100644 --- a/x-pack/plugins/infra/public/components/log_stream/lazy_log_stream_wrapper.tsx +++ b/x-pack/plugins/infra/public/components/log_stream/lazy_log_stream_wrapper.tsx @@ -5,9 +5,9 @@ */ import React from 'react'; -import type { LogStreamProps } from './'; +import type { LogStreamProps } from './log_stream'; -const LazyLogStream = React.lazy(() => import('./')); +const LazyLogStream = React.lazy(() => import('./log_stream')); export const LazyLogStreamWrapper: React.FC = (props) => ( }> diff --git a/x-pack/plugins/infra/public/components/log_stream/index.tsx b/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx similarity index 98% rename from x-pack/plugins/infra/public/components/log_stream/index.tsx rename to x-pack/plugins/infra/public/components/log_stream/log_stream.tsx index b485a21221af2..b7410fda6f6fd 100644 --- a/x-pack/plugins/infra/public/components/log_stream/index.tsx +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx @@ -17,6 +17,7 @@ import { useLogStream } from '../../containers/logs/log_stream'; import { ScrollableLogTextStreamView } from '../logging/log_text_stream'; import { LogColumnRenderConfiguration } from '../../utils/log_column_render_configuration'; import { JsonValue } from '../../../../../../src/plugins/kibana_utils/common'; +import { Query } from '../../../../../../src/plugins/data/common'; const PAGE_THRESHOLD = 2; @@ -55,7 +56,7 @@ export interface LogStreamProps { sourceId?: string; startTimestamp: number; endTimestamp: number; - query?: string; + query?: string | Query; center?: LogEntryCursor; highlight?: string; height?: string | number; diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx new file mode 100644 index 0000000000000..0d6dfc50960f9 --- /dev/null +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { CoreStart } from 'kibana/public'; + +import { I18nProvider } from '@kbn/i18n/react'; +import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { EuiThemeProvider } from '../../../../../../src/plugins/kibana_react/common'; +import { Query, TimeRange } from '../../../../../../src/plugins/data/public'; +import { + Embeddable, + EmbeddableInput, + IContainer, +} from '../../../../../../src/plugins/embeddable/public'; +import { datemathToEpochMillis } from '../../utils/datemath'; +import { LazyLogStreamWrapper } from './lazy_log_stream_wrapper'; + +export const LOG_STREAM_EMBEDDABLE = 'LOG_STREAM_EMBEDDABLE'; + +export interface LogStreamEmbeddableInput extends EmbeddableInput { + timeRange: TimeRange; + query: Query; +} + +export class LogStreamEmbeddable extends Embeddable { + public readonly type = LOG_STREAM_EMBEDDABLE; + private node?: HTMLElement; + + constructor( + private services: CoreStart, + initialInput: LogStreamEmbeddableInput, + parent?: IContainer + ) { + super(initialInput, {}, parent); + } + + public render(node: HTMLElement) { + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + this.node = node; + + this.renderComponent(); + } + + public reload() { + this.renderComponent(); + } + + public destroy() { + super.destroy(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } + + private renderComponent() { + if (!this.node) { + return; + } + + const startTimestamp = datemathToEpochMillis(this.input.timeRange.from); + const endTimestamp = datemathToEpochMillis(this.input.timeRange.to); + + if (!startTimestamp || !endTimestamp) { + return; + } + + ReactDOM.render( + + + +
+ +
+
+
+
, + this.node + ); + } +} diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable_factory.ts b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable_factory.ts new file mode 100644 index 0000000000000..f4d1b83a07593 --- /dev/null +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable_factory.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreStart } from 'kibana/public'; +import { + EmbeddableFactoryDefinition, + IContainer, +} from '../../../../../../src/plugins/embeddable/public'; +import { + LogStreamEmbeddable, + LOG_STREAM_EMBEDDABLE, + LogStreamEmbeddableInput, +} from './log_stream_embeddable'; + +export class LogStreamEmbeddableFactoryDefinition + implements EmbeddableFactoryDefinition { + public readonly type = LOG_STREAM_EMBEDDABLE; + + constructor(private getCoreServices: () => Promise) {} + + public async isEditable() { + const { application } = await this.getCoreServices(); + return application.capabilities.logs.save as boolean; + } + + public async create(initialInput: LogStreamEmbeddableInput, parent?: IContainer) { + const services = await this.getCoreServices(); + return new LogStreamEmbeddable(services, initialInput, parent); + } + + public getDisplayName() { + return 'Log stream'; + } +} diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.tsx index aa3b4532e878e..9fef939733432 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import { EuiButton, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useMemo } from 'react'; import { useVisibilityState } from '../../../utils/use_visibility_state'; @@ -67,7 +67,7 @@ export const LogEntryActionsMenu: React.FunctionComponent<{ - + } closePopover={hide} id="logEntryActionsMenu" diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx index 5684d4068f3be..7d8ca95f9b93b 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx @@ -88,7 +88,7 @@ export const LogEntryFlyout = ({ ) : null} - + {logEntry ? : null} diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts index da7176125dae4..1d9a7a1b1d777 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts @@ -7,7 +7,7 @@ import { useMemo, useEffect } from 'react'; import useSetState from 'react-use/lib/useSetState'; import usePrevious from 'react-use/lib/usePrevious'; -import { esKuery } from '../../../../../../../src/plugins/data/public'; +import { esKuery, esQuery, Query } from '../../../../../../../src/plugins/data/public'; import { fetchLogEntries } from '../log_entries/api/fetch_log_entries'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; import { LogEntryCursor, LogEntry } from '../../../../common/log_entry'; @@ -18,7 +18,7 @@ interface LogStreamProps { sourceId: string; startTimestamp: number; endTimestamp: number; - query?: string; + query?: string | Query; center?: LogEntryCursor; columns?: LogSourceConfigurationProperties['logColumns']; } @@ -84,9 +84,21 @@ export function useLogStream({ }, [prevEndTimestamp, endTimestamp, setState]); const parsedQuery = useMemo(() => { - return query - ? JSON.stringify(esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query))) - : null; + if (!query) { + return null; + } + + let q; + + if (typeof query === 'string') { + q = esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query)); + } else if (query.language === 'kuery') { + q = esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query.query as string)); + } else if (query.language === 'lucene') { + q = esQuery.luceneStringToDsl(query.query as string); + } + + return JSON.stringify(q); }, [query]); // Callbacks diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index 2bbd0067642c0..809046ee1e17b 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -19,6 +19,8 @@ import { } from './types'; import { getLogsHasDataFetcher, getLogsOverviewDataFetcher } from './utils/logs_overview_fetchers'; import { createMetricsHasData, createMetricsFetchData } from './metrics_overview_fetchers'; +import { LOG_STREAM_EMBEDDABLE } from './components/log_stream/log_stream_embeddable'; +import { LogStreamEmbeddableFactoryDefinition } from './components/log_stream/log_stream_embeddable_factory'; export class Plugin implements InfraClientPluginClass { constructor(_context: PluginInitializerContext) {} @@ -46,6 +48,13 @@ export class Plugin implements InfraClientPluginClass { }); } + const getCoreServices = async () => (await core.getStartServices())[0]; + + pluginsSetup.embeddable.registerEmbeddableFactory( + LOG_STREAM_EMBEDDABLE, + new LogStreamEmbeddableFactoryDefinition(getCoreServices) + ); + core.application.register({ id: 'logs', title: i18n.translate('xpack.infra.logs.pluginTitle', { diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts index f1052672978d5..037cfa4b7eb2d 100644 --- a/x-pack/plugins/infra/public/types.ts +++ b/x-pack/plugins/infra/public/types.ts @@ -7,6 +7,7 @@ import type { CoreSetup, CoreStart, Plugin as PluginClass } from 'kibana/public'; import type { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import type { EmbeddableSetup } from '../../../../src/plugins/embeddable/public'; import type { UsageCollectionSetup, UsageCollectionStart, @@ -33,6 +34,7 @@ export interface InfraClientSetupDeps { observability: ObservabilityPluginSetup; triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; usageCollection: UsageCollectionSetup; + embeddable: EmbeddableSetup; } export interface InfraClientStartDeps { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/README.md b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/README.md similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/README.md rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/README.md diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/constants.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/constants.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/constants.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/constants.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/http_requests.helpers.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/http_requests.helpers.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/http_requests.helpers.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/http_requests.helpers.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.helpers.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.helpers.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.test.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.test.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors/processor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors/processor.helpers.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors/uri_parts.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors/uri_parts.test.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors_editor.tsx similarity index 89% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors_editor.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors_editor.tsx index 8fb51ade921a9..3fa245ff96d37 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors_editor.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors_editor.tsx @@ -9,7 +9,7 @@ import { notificationServiceMock, scopedHistoryMock } from 'src/core/public/mock import { LocationDescriptorObject } from 'history'; import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; -import { ProcessorsEditorContextProvider, Props, PipelineProcessorsEditor } from '../'; +import { ProcessorsEditorContextProvider, Props, PipelineEditor } from '../'; import { breadcrumbService, @@ -36,7 +36,7 @@ export const ProcessorsEditorWithDeps: React.FunctionComponent = (props) return ( - + ); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/test_pipeline.helpers.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/test_pipeline.helpers.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/test_pipeline.test.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.test.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/test_pipeline.test.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/_shared.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/_shared.scss similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/_shared.scss rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/_shared.scss diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/add_processor_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/add_processor_button.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/add_processor_button.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/add_processor_button.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/button.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/button.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/button.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.test.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.test.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.test.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/on_failure_processors_title.tsx similarity index 96% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/on_failure_processors_title.tsx index 7adc37d1897d1..fe3e6d79f84d7 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/on_failure_processors_title.tsx @@ -14,7 +14,7 @@ export const OnFailureProcessorsTitle: FunctionComponent = () => { const { services } = useKibana(); return ( -
+

= ({ onLoadJson }) => { +export const PipelineEditor: React.FunctionComponent = ({ onLoadJson }) => { const { state: { processors: allProcessors }, } = usePipelineProcessorsContext(); @@ -52,12 +52,12 @@ export const PipelineProcessorsEditor: React.FunctionComponent = ({ onLoa } return ( -
+
0} /> - + {content} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/constants.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/constants.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/constants.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/constants.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.test.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/processors_reducer.test.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.test.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/processors_reducer.test.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/processors_reducer.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/processors_reducer.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/utils.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/utils.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/utils.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/serialize.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/serialize.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/serialize.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/serialize.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/types.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/types.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/types.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/types.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/use_is_mounted.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/use_is_mounted.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/use_is_mounted.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/use_is_mounted.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.test.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.test.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx index ffd82b0bbaf35..ac8612a36dd7e 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx @@ -11,7 +11,7 @@ import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from import { useForm, Form, FormConfig } from '../../../shared_imports'; import { Pipeline, Processor } from '../../../../common/types'; -import { OnUpdateHandlerArg, OnUpdateHandler } from '../pipeline_processors_editor'; +import { OnUpdateHandlerArg, OnUpdateHandler } from '../pipeline_editor'; import { PipelineRequestFlyout } from './pipeline_request_flyout'; import { PipelineFormFields } from './pipeline_form_fields'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx index a7ffe7ba02caa..b1b2e04e7d0dc 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx @@ -16,8 +16,8 @@ import { ProcessorsEditorContextProvider, OnUpdateHandler, OnDoneLoadJsonHandler, - PipelineProcessorsEditor, -} from '../pipeline_processors_editor'; + PipelineEditor, +} from '../pipeline_editor'; interface Props { processors: Processor[]; @@ -119,7 +119,7 @@ export const PipelineFormFields: React.FunctionComponent = ({ onUpdate={onProcessorsUpdate} value={{ processors, onFailure }} > - + ); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss deleted file mode 100644 index d5592b87dda51..0000000000000 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss +++ /dev/null @@ -1,11 +0,0 @@ -.pipelineProcessorsEditor { - margin-bottom: $euiSizeXL; - - &__container { - background-color: $euiColorLightestShade; - } - - &__onFailureTitle { - padding-left: $euiSizeS; - } -} diff --git a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap index dc53f3a2bc2a7..6423a9f6190a7 100644 --- a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap +++ b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap @@ -13,10 +13,8 @@ exports[`DragDrop items that have droppable=false get special styling when anoth +
+ +
`; diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.scss b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss index ded0b4552a4e5..d0a4019055d57 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.scss +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss @@ -52,7 +52,7 @@ } } -.lnsDragDrop__reorderableContainer { +.lnsDragDrop__container { position: relative; } @@ -63,11 +63,18 @@ height: calc(100% + #{$lnsLayerPanelDimensionMargin}); } -.lnsDragDrop-isReorderable { +.lnsDragDrop-translatableDrop { + transform: translateY(0); transition: transform $euiAnimSpeedFast ease-in-out; pointer-events: none; } +.lnsDragDrop-translatableDrag { + transform: translateY(0); + transition: transform $euiAnimSpeedFast ease-in-out; + position: relative; +} + // Draggable item when it is moving .lnsDragDrop-isHidden { opacity: 0; diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx index 07b489d29ad06..9e1583b0c6e81 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx @@ -6,11 +6,33 @@ import React from 'react'; import { render, mount } from 'enzyme'; -import { DragDrop, ReorderableDragDrop, DropToHandler, DropHandler } from './drag_drop'; -import { ChildDragDropProvider, ReorderProvider } from './providers'; +import { DragDrop, DropHandler } from './drag_drop'; +import { + ChildDragDropProvider, + DragContextState, + ReorderProvider, + DragDropIdentifier, + ActiveDropTarget, +} from './providers'; +import { act } from 'react-dom/test-utils'; jest.useFakeTimers(); +const defaultContext = { + dragging: undefined, + setDragging: jest.fn(), + setActiveDropTarget: () => {}, + activeDropTarget: undefined, + keyboardMode: false, + setKeyboardMode: () => {}, + setA11yMessage: jest.fn(), +}; + +const dataTransfer = { + setData: jest.fn(), + getData: jest.fn(), +}; + describe('DragDrop', () => { const value = { id: '1', label: 'hello' }; test('renders if nothing is being dragged', () => { @@ -26,7 +48,7 @@ describe('DragDrop', () => { test('dragover calls preventDefault if droppable is true', () => { const preventDefault = jest.fn(); const component = mount( - + ); @@ -39,7 +61,7 @@ describe('DragDrop', () => { test('dragover does not call preventDefault if droppable is false', () => { const preventDefault = jest.fn(); const component = mount( - + ); @@ -51,13 +73,9 @@ describe('DragDrop', () => { test('dragstart sets dragging in the context', async () => { const setDragging = jest.fn(); - const dataTransfer = { - setData: jest.fn(), - getData: jest.fn(), - }; const component = mount( - + @@ -79,7 +97,11 @@ describe('DragDrop', () => { const onDrop = jest.fn(); const component = mount( - + @@ -93,7 +115,7 @@ describe('DragDrop', () => { expect(preventDefault).toBeCalled(); expect(stopPropagation).toBeCalled(); expect(setDragging).toBeCalledWith(undefined); - expect(onDrop).toBeCalledWith({ id: '2', label: 'hi' }); + expect(onDrop).toBeCalledWith({ id: '2', label: 'hi' }, { id: '1', label: 'hello' }); }); test('drop function is not called on droppable=false', async () => { @@ -103,7 +125,7 @@ describe('DragDrop', () => { const onDrop = jest.fn(); const component = mount( - + @@ -127,6 +149,7 @@ describe('DragDrop', () => { throw x; }} droppable + value={value} > @@ -137,11 +160,11 @@ describe('DragDrop', () => { test('items that have droppable=false get special styling when another item is dragged', () => { const component = mount( - {}}> + - {}} droppable={false}> + {}} droppable={false} value={{ id: '2' }}> @@ -153,17 +176,25 @@ describe('DragDrop', () => { test('additional styles are reflected in the className until drop', () => { let dragging: { id: '1' } | undefined; const getAdditionalClasses = jest.fn().mockReturnValue('additional'); + let activeDropTarget; + const component = mount( { dragging = { id: '1' }; }} + setActiveDropTarget={(val) => { + activeDropTarget = { activeDropTarget: val }; + }} + activeDropTarget={activeDropTarget} > {}} droppable getAdditionalClassesOnEnter={getAdditionalClasses} @@ -173,10 +204,6 @@ describe('DragDrop', () => { ); - const dataTransfer = { - setData: jest.fn(), - getData: jest.fn(), - }; component .find('[data-test-subj="lnsDragDrop"]') .first() @@ -184,40 +211,91 @@ describe('DragDrop', () => { jest.runAllTimers(); component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover'); - expect(component.find('.additional')).toHaveLength(1); - - component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragleave'); + component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('drop'); expect(component.find('.additional')).toHaveLength(0); + }); + + test('additional enter styles are reflected in the className until dragleave', () => { + let dragging: { id: '1' } | undefined; + const getAdditionalClasses = jest.fn().mockReturnValue('additional'); + const setActiveDropTarget = jest.fn(); + + const component = mount( + { + dragging = { id: '1' }; + }} + setActiveDropTarget={setActiveDropTarget} + activeDropTarget={ + ({ activeDropTarget: value } as unknown) as DragContextState['activeDropTarget'] + } + keyboardMode={false} + setKeyboardMode={(keyboardMode) => true} + > + + + + {}} + droppable + getAdditionalClassesOnEnter={getAdditionalClasses} + > + + + + ); + + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + jest.runAllTimers(); component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover'); - component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('drop'); - expect(component.find('.additional')).toHaveLength(0); + expect(component.find('.additional')).toHaveLength(1); + + component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragleave'); + expect(setActiveDropTarget).toBeCalledWith(undefined); }); describe('reordering', () => { const mountComponent = ( - dragging: { id: '1' } | undefined, - onDrop: DropHandler = jest.fn(), - dropTo: DropToHandler = jest.fn() - ) => - mount( - { - dragging = { id: '1' }; - }} - > + dragContext: Partial | undefined, + onDrop: DropHandler = jest.fn() + ) => { + let dragging = dragContext?.dragging; + let keyboardMode = !!dragContext?.keyboardMode; + let activeDropTarget = dragContext?.activeDropTarget; + const baseContext = { + dragging, + setDragging: (val?: DragDropIdentifier) => { + dragging = val; + }, + keyboardMode, + setKeyboardMode: jest.fn((mode) => { + keyboardMode = mode; + }), + setActiveDropTarget: (target?: DragDropIdentifier) => { + activeDropTarget = { activeDropTarget: target } as ActiveDropTarget; + }, + activeDropTarget, + setA11yMessage: jest.fn(), + }; + return mount( + 1 @@ -227,12 +305,11 @@ describe('DragDrop', () => { droppable dragType="reorder" dropType="reorder" - itemsInGroup={['1', '2', '3']} + reorderableGroup={[{ id: '1' }, { id: '2' }, { id: '3' }]} value={{ id: '2', }} onDrop={onDrop} - dropTo={dropTo} > 2 @@ -242,132 +319,270 @@ describe('DragDrop', () => { droppable dragType="reorder" dropType="reorder" - itemsInGroup={['1', '2', '3']} + reorderableGroup={[{ id: '1' }, { id: '2' }, { id: '3' }]} value={{ id: '3', }} onDrop={onDrop} - dropTo={dropTo} > 3 ); - test(`ReorderableDragDrop component doesn't appear for groups of 1 or less`, () => { - let dragging; - const component = mount( - { - dragging = { id: '1' }; - }} - > - - -
- - - - ); - expect(component.find(ReorderableDragDrop)).toHaveLength(0); - }); - test(`Reorderable component renders properly`, () => { + }; + test(`Inactive reorderable group renders properly`, () => { const component = mountComponent(undefined, jest.fn()); - expect(component.find(ReorderableDragDrop)).toHaveLength(3); + expect(component.find('.lnsDragDrop-reorderable')).toHaveLength(3); }); - test(`Elements between dragged and drop get extra class to show the reorder effect when dragging`, () => { - const component = mountComponent({ id: '1' }, jest.fn()); - const dataTransfer = { - setData: jest.fn(), - getData: jest.fn(), - }; - component - .find(ReorderableDragDrop) - .first() - .find('[data-test-subj="lnsDragDrop"]') - .simulate('dragstart', { dataTransfer }); - jest.runAllTimers(); - component.find('[data-test-subj="lnsDragDrop-reorderableDrop"]').at(2).simulate('dragover'); + test(`Reorderable group with lifted element renders properly`, () => { + const setDragging = jest.fn(); + const setA11yMessage = jest.fn(); + const component = mountComponent( + { dragging: { id: '1' }, setA11yMessage, setDragging }, + jest.fn() + ); + act(() => { + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + jest.runAllTimers(); + }); + + expect(setDragging).toBeCalledWith({ id: '1' }); + expect(setA11yMessage).toBeCalledWith('You have lifted an item 1 in position 1'); + expect( + component + .find('[data-test-subj="lnsDragDrop-reorderableGroup"]') + .hasClass('lnsDragDrop-isActiveGroup') + ).toEqual(true); + }); + + test(`Reordered elements get extra styles to show the reorder effect when dragging`, () => { + const component = mountComponent({ dragging: { id: '1' } }, jest.fn()); + + act(() => { + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + jest.runAllTimers(); + }); + + component + .find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]') + .at(1) + .simulate('dragover'); expect( component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(0).prop('style') - ).toEqual({}); + ).toEqual(undefined); expect( - component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(1).prop('style') + component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(0).prop('style') ).toEqual({ - transform: 'translateY(-40px)', + transform: 'translateY(-8px)', }); expect( - component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(2).prop('style') + component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(1).prop('style') ).toEqual({ - transform: 'translateY(-40px)', + transform: 'translateY(-8px)', }); - component.find('[data-test-subj="lnsDragDrop-reorderableDrop"]').at(2).simulate('dragleave'); + component + .find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]') + .at(1) + .simulate('dragleave'); expect( - component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(1).prop('style') - ).toEqual({}); + component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(0).prop('style') + ).toEqual(undefined); expect( - component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(2).prop('style') - ).toEqual({}); + component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(1).prop('style') + ).toEqual(undefined); }); + test(`Dropping an item runs onDrop function`, () => { + const setDragging = jest.fn(); + const setA11yMessage = jest.fn(); const preventDefault = jest.fn(); const stopPropagation = jest.fn(); const onDrop = jest.fn(); - const component = mountComponent({ id: '1' }, onDrop); + const component = mountComponent( + { dragging: { id: '1' }, setA11yMessage, setDragging }, + onDrop + ); component - .find('[data-test-subj="lnsDragDrop-reorderableDrop"]') + .find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]') .at(1) .simulate('drop', { preventDefault, stopPropagation }); + jest.runAllTimers(); + + expect(setA11yMessage).toBeCalledWith( + 'You have dropped the item. You have moved the item from position 1 to positon 3' + ); expect(preventDefault).toBeCalled(); expect(stopPropagation).toBeCalled(); - expect(onDrop).toBeCalledWith({ id: '1' }); + expect(onDrop).toBeCalledWith({ id: '1' }, { id: '3' }); }); - test(`Keyboard navigation: user can reorder an element`, () => { + + test(`Keyboard navigation: user can drop element to an activeDropTarget`, () => { const onDrop = jest.fn(); - const dropTo = jest.fn(); - const component = mountComponent({ id: '1' }, onDrop, dropTo); + const component = mountComponent( + { + dragging: { id: '1' }, + activeDropTarget: { activeDropTarget: { id: '3' } } as ActiveDropTarget, + keyboardMode: true, + }, + onDrop + ); const keyboardHandler = component - .find(ReorderableDragDrop) - .at(1) - .find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .simulate('focus'); + + act(() => { + keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); + keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); + keyboardHandler.simulate('keydown', { key: 'Enter' }); + }); + expect(onDrop).toBeCalledWith({ id: '1' }, { id: '3' }); + }); + + test(`Keyboard Navigation: Reordered elements get extra styles to show the reorder effect`, () => { + const setA11yMessage = jest.fn(); + const component = mountComponent( + { dragging: { id: '1' }, keyboardMode: true, setA11yMessage }, + jest.fn() + ); + const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); keyboardHandler.simulate('keydown', { key: 'Space' }); keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); - expect(dropTo).toBeCalledWith('3'); - keyboardHandler.simulate('keydown', { key: 'ArrowUp' }); - expect(dropTo).toBeCalledWith('1'); + expect( + component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(0).prop('style') + ).toEqual({ + transform: 'translateY(+8px)', + }); + expect( + component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(0).prop('style') + ).toEqual({ + transform: 'translateY(-40px)', + }); + expect( + component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(1).prop('style') + ).toEqual(undefined); + expect(setA11yMessage).toBeCalledWith( + 'You have moved the item 1 from position 1 to position 2' + ); + + component + .find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]') + .at(1) + .simulate('dragleave'); + expect( + component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(0).prop('style') + ).toEqual(undefined); + expect( + component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(1).prop('style') + ).toEqual(undefined); }); + test(`Keyboard Navigation: User cannot move an element outside of the group`, () => { const onDrop = jest.fn(); - const dropTo = jest.fn(); - const component = mountComponent({ id: '1' }, onDrop, dropTo); - const keyboardHandler = component - .find(ReorderableDragDrop) - .first() - .find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); + const setActiveDropTarget = jest.fn(); + const setA11yMessage = jest.fn(); + const component = mountComponent( + { dragging: { id: '1' }, keyboardMode: true, setActiveDropTarget, setA11yMessage }, + onDrop + ); + const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); keyboardHandler.simulate('keydown', { key: 'Space' }); keyboardHandler.simulate('keydown', { key: 'ArrowUp' }); - expect(dropTo).not.toHaveBeenCalled(); + expect(setActiveDropTarget).not.toHaveBeenCalled(); + keyboardHandler.simulate('keydown', { key: 'Space' }); keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); - expect(dropTo).toBeCalledWith('2'); + + expect(setActiveDropTarget).toBeCalledWith({ id: '2' }); + expect(setA11yMessage).toBeCalledWith( + 'You have moved the item 1 from position 1 to position 2' + ); + }); + + test(`Keyboard Navigation: User cannot drop element to itself`, () => { + const setActiveDropTarget = jest.fn(); + const setA11yMessage = jest.fn(); + const component = mount( + + + + 1 + + + 2 + + + + ); + const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); + + keyboardHandler.simulate('keydown', { key: 'Space' }); + keyboardHandler.simulate('keydown', { key: 'ArrowUp' }); + expect(setActiveDropTarget).toBeCalledWith({ id: '1' }); + expect(setA11yMessage).toBeCalledWith('You have moved back the item 1 to position 1'); + }); + + test(`Keyboard Navigation: Doesn't call onDrop when movement is cancelled`, () => { + const setA11yMessage = jest.fn(); + const onDrop = jest.fn(); + + const component = mountComponent({ dragging: { id: '1' }, setA11yMessage }, onDrop); + const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); + keyboardHandler.simulate('keydown', { key: 'Space' }); + keyboardHandler.simulate('keydown', { key: 'Escape' }); + + jest.runAllTimers(); + + expect(onDrop).not.toHaveBeenCalled(); + expect(setA11yMessage).toBeCalledWith( + 'Movement cancelled. The item has returned to its starting position 1' + ); + keyboardHandler.simulate('keydown', { key: 'Space' }); + keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); + keyboardHandler.simulate('blur'); + + expect(onDrop).not.toHaveBeenCalled(); + expect(setA11yMessage).toBeCalledWith( + 'Movement cancelled. The item has returned to its starting position 1' + ); }); }); }); diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx index 32facbf8e84a8..2dbcfab8d5738 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -5,11 +5,17 @@ */ import './drag_drop.scss'; -import React, { useState, useContext, useEffect } from 'react'; +import React, { useContext, useEffect, memo } from 'react'; import classNames from 'classnames'; import { keys, EuiScreenReaderOnly } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { DragContext, DragContextState, ReorderContext, ReorderState } from './providers'; +import { + DragDropIdentifier, + DragContext, + DragContextState, + ReorderContext, + ReorderState, + reorderAnnouncements, +} from './providers'; import { trackUiEvent } from '../lens_ui_telemetry'; export type DroppableEvent = React.DragEvent; @@ -17,12 +23,7 @@ export type DroppableEvent = React.DragEvent; /** * A function that handles a drop event. */ -export type DropHandler = (item: unknown) => void; - -/** - * A function that handles a dropTo event. - */ -export type DropToHandler = (dropTargetId: string) => void; +export type DropHandler = (dropped: DragDropIdentifier, dropTarget: DragDropIdentifier) => void; /** * The base props to the DragDrop component. @@ -32,24 +33,20 @@ interface BaseProps { * The CSS class(es) for the root element. */ className?: string; - /** - * The event handler that fires when this item - * is dropped to the one with passed id - * + * The label for accessibility */ - dropTo?: DropToHandler; + label?: string; + /** * The event handler that fires when an item * is dropped onto this DragDrop component. */ onDrop?: DropHandler; /** - * The value associated with this item, if it is draggable. - * If this component is dragged, this will be the value of - * "dragging" in the root drag/drop context. + * The value associated with this item. */ - value?: DragContextState['dragging']; + value: DragDropIdentifier; /** * Optional comparison function to check whether a value is the dragged one @@ -60,7 +57,10 @@ interface BaseProps { * The React element which will be passed the draggable handlers */ children: React.ReactElement; - + /** + * Indicates whether or not this component is draggable. + */ + draggable?: boolean; /** * Indicates whether or not the currently dragged item * can be dropped onto this component. @@ -75,12 +75,12 @@ interface BaseProps { /** * The optional test subject associated with this DOM element. */ - 'data-test-subj'?: string; + dataTestSubj?: string; /** * items belonging to the same group that can be reordered */ - itemsInGroup?: string[]; + reorderableGroup?: DragDropIdentifier[]; /** * Indicates to the user whether the currently dragged item @@ -93,34 +93,46 @@ interface BaseProps { * replace something that is existing or add a new one */ dropType?: 'add' | 'replace' | 'reorder'; + + /** + * temporary flag to exclude the draggable elements that don't have keyboard nav yet. To be removed along with the feature development + */ + noKeyboardSupportYet?: boolean; } /** * The props for a draggable instance of that component. */ -interface DraggableProps extends BaseProps { - /** - * Indicates whether or not this component is draggable. - */ - draggable: true; +interface DragInnerProps extends BaseProps { /** * The label, which should be attached to the drag event, and which will e.g. * be used if the element will be dropped into a text field. */ - label: string; + label?: string; + isDragging: boolean; + keyboardMode: boolean; + setKeyboardMode: DragContextState['setKeyboardMode']; + setDragging: DragContextState['setDragging']; + setActiveDropTarget: DragContextState['setActiveDropTarget']; + setA11yMessage: DragContextState['setA11yMessage']; + activeDropTarget: DragContextState['activeDropTarget']; + onDragStart?: ( + target?: + | DroppableEvent['currentTarget'] + | React.KeyboardEvent['currentTarget'] + ) => void; + onDragEnd?: () => void; + extraKeyboardHandler?: (e: React.KeyboardEvent) => void; } /** * The props for a non-draggable instance of that component. */ -interface NonDraggableProps extends BaseProps { - /** - * Indicates whether or not this component is draggable. - */ - draggable?: false; -} +interface DropInnerProps extends BaseProps, DragContextState { + isDragging: boolean; -type Props = DraggableProps | NonDraggableProps; + isNotDroppable: boolean; +} /** * A draggable / droppable item. Items can be both draggable and droppable at @@ -129,40 +141,189 @@ type Props = DraggableProps | NonDraggableProps; * @param props */ -export const DragDrop = (props: Props) => { - const { dragging, setDragging } = useContext(DragContext); - const { value, draggable, droppable, isValueEqual } = props; +const lnsLayerPanelDimensionMargin = 8; - return ( - { + const { + dragging, + setDragging, + keyboardMode, + setKeyboardMode, + activeDropTarget, + setActiveDropTarget, + setA11yMessage, + } = useContext(DragContext); + + const { value, draggable, droppable, reorderableGroup } = props; + + const isDragging = !!(draggable && value.id === dragging?.id); + + const dragProps = { + ...props, + isDragging, + keyboardMode: isDragging ? keyboardMode : false, // optimization to not rerender all dragging components + activeDropTarget: isDragging ? activeDropTarget : undefined, // optimization to not rerender all dragging components + setKeyboardMode, + setDragging, + setActiveDropTarget, + setA11yMessage, + }; + + const dropProps = { + ...props, + setKeyboardMode, + keyboardMode, + dragging, + setDragging, + activeDropTarget, + setActiveDropTarget, + isDragging, + setA11yMessage, + isNotDroppable: + // If the configuration has provided a droppable flag, but this particular item is not + // droppable, then it should be less prominent. Ignores items that are both + // draggable and drop targets + !!(droppable === false && dragging && value.id !== dragging.id), + }; + + if (draggable && !droppable) { + if (reorderableGroup && reorderableGroup.length > 1) { + return ( + + ); + } else { + return ; + } + } + if ( + reorderableGroup && + reorderableGroup.length > 1 && + reorderableGroup?.some((i) => i.id === value.id) + ) { + return ; + } + return ; +}; + +const DragInner = memo(function DragDropInner({ + dataTestSubj, + className, + value, + children, + setDragging, + setKeyboardMode, + setActiveDropTarget, + label = '', + keyboardMode, + isDragging, + activeDropTarget, + onDrop, + dragType, + onDragStart, + onDragEnd, + extraKeyboardHandler, + noKeyboardSupportYet, +}: DragInnerProps) { + const dragStart = (e?: DroppableEvent | React.KeyboardEvent) => { + // Setting stopPropgagation causes Chrome failures, so + // we are manually checking if we've already handled this + // in a nested child, and doing nothing if so... + if (e && 'dataTransfer' in e && e.dataTransfer.getData('text')) { + return; + } + + // We only can reach the dragStart method if the element is draggable, + // so we know we have DraggableProps if we reach this code. + if (e && 'dataTransfer' in e) { + e.dataTransfer.setData('text', label); + } + + // Chrome causes issues if you try to render from within a + // dragStart event, so we drop a setTimeout to avoid that. + + const currentTarget = e?.currentTarget; + setTimeout(() => { + setDragging(value); + if (onDragStart) { + onDragStart(currentTarget); } - isNotDroppable={ - // If the configuration has provided a droppable flag, but this particular item is not - // droppable, then it should be less prominent. Ignores items that are both - // draggable and drop targets - droppable === false && Boolean(dragging) && value !== dragging + }); + }; + + const dragEnd = (e?: DroppableEvent) => { + e?.stopPropagation(); + setDragging(undefined); + setActiveDropTarget(undefined); + setKeyboardMode(false); + if (onDragEnd) { + onDragEnd(); + } + }; + + const dropToActiveDropTarget = () => { + if (isDragging && activeDropTarget?.activeDropTarget) { + trackUiEvent('drop_total'); + if (onDrop) { + onDrop(value, activeDropTarget.activeDropTarget); } - /> + } + }; + + return ( +
+ {!noKeyboardSupportYet && ( + +
); -}; +}); -const DragDropInner = React.memo(function DragDropInner( - props: Props & - DragContextState & { - isDragging: boolean; - isNotDroppable: boolean; - } -) { - const [state, setState] = useState({ - isActive: false, - dragEnterClassNames: '', - }); +const DropInner = memo(function DropInner(props: DropInnerProps) { const { + dataTestSubj, className, onDrop, value, @@ -175,10 +336,16 @@ const DragDropInner = React.memo(function DragDropInner( isNotDroppable, dragType = 'copy', dropType = 'add', - dropTo, - itemsInGroup, + keyboardMode, + setKeyboardMode, + activeDropTarget, + setActiveDropTarget, + getAdditionalClassesOnEnter, } = props; + const activeDropTargetMatches = + activeDropTarget?.activeDropTarget && activeDropTarget.activeDropTarget.id === value.id; + const isMoveDragging = isDragging && dragType === 'move'; const classes = classNames( @@ -186,339 +353,364 @@ const DragDropInner = React.memo(function DragDropInner( { 'lnsDragDrop-isDraggable': draggable, 'lnsDragDrop-isDragging': isDragging, - 'lnsDragDrop-isHidden': isMoveDragging, + 'lnsDragDrop-isHidden': isMoveDragging && !keyboardMode, 'lnsDragDrop-isDroppable': !draggable, 'lnsDragDrop-isDropTarget': droppable && dragType !== 'reorder', - 'lnsDragDrop-isActiveDropTarget': droppable && state.isActive && dragType !== 'reorder', + 'lnsDragDrop-isActiveDropTarget': + droppable && activeDropTargetMatches && dragType !== 'reorder', 'lnsDragDrop-isNotDroppable': !isMoveDragging && isNotDroppable, - 'lnsDragDrop-isReplacing': droppable && state.isActive && dropType === 'replace', + 'lnsDragDrop-isReplacing': droppable && activeDropTargetMatches && dropType === 'replace', }, - state.dragEnterClassNames - ); - - const dragStart = (e: DroppableEvent) => { - // Setting stopPropgagation causes Chrome failures, so - // we are manually checking if we've already handled this - // in a nested child, and doing nothing if so... - if (e.dataTransfer.getData('text')) { - return; + getAdditionalClassesOnEnter && { + [getAdditionalClassesOnEnter()]: activeDropTargetMatches, } - - // We only can reach the dragStart method if the element is draggable, - // so we know we have DraggableProps if we reach this code. - e.dataTransfer.setData('text', (props as DraggableProps).label); - - // Chrome causes issues if you try to render from within a - // dragStart event, so we drop a setTimeout to avoid that. - setState({ ...state }); - setTimeout(() => setDragging(value)); - }; - - const dragEnd = (e: DroppableEvent) => { - e.stopPropagation(); - setDragging(undefined); - }; + ); const dragOver = (e: DroppableEvent) => { if (!droppable) { return; } - e.preventDefault(); // An optimization to prevent a bunch of React churn. - if (!state.isActive) { - setState({ - ...state, - isActive: true, - dragEnterClassNames: props.getAdditionalClassesOnEnter - ? props.getAdditionalClassesOnEnter() - : '', - }); + // todo: replace with custom function ? + if (!activeDropTargetMatches) { + setActiveDropTarget(value); } }; const dragLeave = () => { - setState({ ...state, isActive: false, dragEnterClassNames: '' }); + setActiveDropTarget(undefined); }; - const drop = (e: DroppableEvent) => { + const drop = (e: DroppableEvent | React.KeyboardEvent) => { e.preventDefault(); e.stopPropagation(); - setState({ ...state, isActive: false, dragEnterClassNames: '' }); - setDragging(undefined); - - if (onDrop && droppable) { + if (onDrop && droppable && dragging) { trackUiEvent('drop_total'); - onDrop(dragging); + onDrop(dragging, value); } + setActiveDropTarget(undefined); + setDragging(undefined); + setKeyboardMode(false); }; + return ( + <> + {React.cloneElement(children, { + 'data-test-subj': dataTestSubj || 'lnsDragDrop', + className: classNames(children.props.className, classes, className), + onDragOver: dragOver, + onDragLeave: dragLeave, + onDrop: drop, + draggable, + })} + + ); +}); - const isReorderDragging = !!(dragging && itemsInGroup?.includes(dragging.id)); +const ReorderableDrag = memo(function ReorderableDrag( + props: DragInnerProps & { reorderableGroup: DragDropIdentifier[]; dragging?: DragDropIdentifier } +) { + const { + reorderState: { isReorderOn, reorderedItems, direction }, + setReorderState, + } = useContext(ReorderContext); - if ( - draggable && - itemsInGroup?.length && - itemsInGroup.length > 1 && - value?.id && - dropTo && - (!dragging || isReorderDragging) - ) { - const { label } = props as DraggableProps; - return ( - - {children} - - ); - } - return React.cloneElement(children, { - 'data-test-subj': props['data-test-subj'] || 'lnsDragDrop', - className: classNames(children.props.className, classes, className), - onDragOver: dragOver, - onDragLeave: dragLeave, - onDrop: drop, - draggable, - onDragEnd: dragEnd, - onDragStart: dragStart, - }); -}); + const { + value, + setActiveDropTarget, + label = '', + keyboardMode, + isDragging, + activeDropTarget, + reorderableGroup, + onDrop, + setA11yMessage, + } = props; -const getKeyboardReorderMessageMoved = ( - itemLabel: string, - position: number, - prevPosition: number -) => - i18n.translate('xpack.lens.dragDrop.elementMoved', { - defaultMessage: `You have moved the item {itemLabel} from position {prevPosition} to position {position}`, - values: { - itemLabel, - position, - prevPosition, - }, - }); - -const getKeyboardReorderMessageLifted = (itemLabel: string, position: number) => - i18n.translate('xpack.lens.dragDrop.elementLifted', { - defaultMessage: `You have lifted an item {itemLabel} in position {position}`, - values: { - itemLabel, - position, - }, - }); + const currentIndex = reorderableGroup.findIndex((i) => i.id === value.id); + + const isFocusInGroup = keyboardMode + ? isDragging && + (!activeDropTarget?.activeDropTarget || + reorderableGroup.some((i) => i.id === activeDropTarget?.activeDropTarget?.id)) + : isDragging; + + useEffect(() => { + setReorderState((s: ReorderState) => ({ + ...s, + isReorderOn: isFocusInGroup, + })); + }, [setReorderState, isFocusInGroup]); + + const onReorderableDragStart = ( + currentTarget?: + | DroppableEvent['currentTarget'] + | React.KeyboardEvent['currentTarget'] + ) => { + if (currentTarget) { + const height = currentTarget.offsetHeight + lnsLayerPanelDimensionMargin; + setReorderState((s: ReorderState) => ({ + ...s, + draggingHeight: height, + })); + } -const lnsLayerPanelDimensionMargin = 8; + setA11yMessage(reorderAnnouncements.lifted(label, currentIndex + 1)); + }; -export const ReorderableDragDrop = ({ - draggingProps, - dropProps, - children, - label, - dropTo, - className, - dataTestSubj, -}: { - draggingProps: { - className: string; - draggable: Props['draggable']; - onDragEnd: (e: DroppableEvent) => void; - onDragStart: (e: DroppableEvent) => void; - isReorderDragging: boolean; + const onReorderableDragEnd = () => { + resetReorderState(); + setA11yMessage(reorderAnnouncements.cancelled(currentIndex + 1)); }; - dropProps: { - onDrop: (e: DroppableEvent) => void; - onDragOver: (e: DroppableEvent) => void; - onDragLeave: () => void; - dragging: DragContextState['dragging']; - droppable: DraggableProps['droppable']; - itemsInGroup: string[]; - id: string; - isActive: boolean; + + const onReorderableDrop = (dragging: DragDropIdentifier, target: DragDropIdentifier) => { + if (onDrop) { + onDrop(dragging, target); + const targetIndex = reorderableGroup.findIndex( + (i) => i.id === activeDropTarget?.activeDropTarget?.id + ); + + resetReorderState(); + setA11yMessage(reorderAnnouncements.dropped(targetIndex + 1, currentIndex + 1)); + } }; - children: React.ReactElement; - label: string; - dropTo: DropToHandler; - className?: string; - dataTestSubj: string; -}) => { - const { itemsInGroup, dragging, id, droppable } = dropProps; - const { reorderState, setReorderState } = useContext(ReorderContext); - const { isReorderOn, reorderedItems, draggingHeight, direction, groupId } = reorderState; - const currentIndex = itemsInGroup.indexOf(id); + const resetReorderState = () => + setReorderState((s: ReorderState) => ({ + ...s, + reorderedItems: [], + })); + + const extraKeyboardHandler = (e: React.KeyboardEvent) => { + if (isReorderOn && keyboardMode) { + e.stopPropagation(); + e.preventDefault(); + let activeDropTargetIndex = reorderableGroup.findIndex((i) => i.id === value.id); + if (activeDropTarget?.activeDropTarget) { + const index = reorderableGroup.findIndex( + (i) => i.id === activeDropTarget.activeDropTarget?.id + ); + if (index !== -1) activeDropTargetIndex = index; + } + if (keys.ARROW_DOWN === e.key) { + if (activeDropTargetIndex < reorderableGroup.length - 1) { + setA11yMessage( + reorderAnnouncements.moved(label, activeDropTargetIndex + 2, currentIndex + 1) + ); + onReorderableDragOver(reorderableGroup[activeDropTargetIndex + 1]); + } + } else if (keys.ARROW_UP === e.key) { + if (activeDropTargetIndex > 0) { + setA11yMessage( + reorderAnnouncements.moved(label, activeDropTargetIndex, currentIndex + 1) + ); + + onReorderableDragOver(reorderableGroup[activeDropTargetIndex - 1]); + } + } + } + }; - useEffect( - () => + const onReorderableDragOver = (target: DragDropIdentifier) => { + let droppingIndex = currentIndex; + if (keyboardMode && 'id' in target) { + setActiveDropTarget(target); + droppingIndex = reorderableGroup.findIndex((i) => i.id === target.id); + } + const draggingIndex = reorderableGroup.findIndex((i) => i.id === value?.id); + if (draggingIndex === -1) { + return; + } + + if (draggingIndex === droppingIndex) { setReorderState((s: ReorderState) => ({ ...s, - isReorderOn: draggingProps.isReorderDragging, - })), - [draggingProps.isReorderDragging, setReorderState] - ); + reorderedItems: [], + })); + } + + setReorderState((s: ReorderState) => + draggingIndex < droppingIndex + ? { + ...s, + reorderedItems: reorderableGroup.slice(draggingIndex + 1, droppingIndex + 1), + direction: '-', + } + : { + ...s, + reorderedItems: reorderableGroup.slice(droppingIndex, draggingIndex), + direction: '+', + } + ); + }; + + const areItemsReordered = isDragging && keyboardMode && reorderedItems.length; return (
- -
+ ); +}); + +const ReorderableDrop = memo(function ReorderableDrop( + props: DropInnerProps & { reorderableGroup: DragDropIdentifier[] } +) { + const { + onDrop, + value, + droppable, + dragging, + setDragging, + setKeyboardMode, + activeDropTarget, + setActiveDropTarget, + reorderableGroup, + setA11yMessage, + } = props; + + const currentIndex = reorderableGroup.findIndex((i) => i.id === value.id); + const activeDropTargetMatches = + activeDropTarget?.activeDropTarget && activeDropTarget.activeDropTarget.id === value.id; + + const { + reorderState: { isReorderOn, reorderedItems, draggingHeight, direction }, + setReorderState, + } = useContext(ReorderContext); + + const heightRef = React.useRef(null); + + const isReordered = + isReorderOn && reorderedItems.some((el) => el.id === value.id) && reorderedItems.length; + + useEffect(() => { + if (isReordered && heightRef.current?.clientHeight) { + setReorderState((s) => ({ + ...s, + reorderedItems: s.reorderedItems.map((el) => + el.id === value.id + ? { + ...el, + height: heightRef.current?.clientHeight, } - } - }} - /> - - {React.cloneElement(children, { - ['data-test-subj']: 'lnsDragDrop-reorderableDrag', - draggable: draggingProps.draggable, - onDragEnd: draggingProps.onDragEnd, - onDragStart: (e: DroppableEvent) => { - const height = e.currentTarget.offsetHeight + lnsLayerPanelDimensionMargin; - setReorderState((s: ReorderState) => ({ + : el + ), + })); + } + }, [isReordered, setReorderState, value.id]); + + const onReorderableDragOver = (e: DroppableEvent) => { + if (!droppable) { + return; + } + e.preventDefault(); + + // An optimization to prevent a bunch of React churn. + // todo: replace with custom function ? + if (!activeDropTargetMatches) { + setActiveDropTarget(value); + } + + const draggingIndex = reorderableGroup.findIndex((i) => i.id === dragging?.id); + + if (!dragging || draggingIndex === -1) { + return; + } + const droppingIndex = currentIndex; + if (draggingIndex === droppingIndex) { + setReorderState((s: ReorderState) => ({ + ...s, + reorderedItems: [], + })); + } + + setReorderState((s: ReorderState) => + draggingIndex < droppingIndex + ? { ...s, - draggingHeight: height, - })); - draggingProps.onDragStart(e); - }, - className: classNames( - draggingProps.className, - { - 'lnsDragDrop-isKeyboardModeActive': isReorderOn, - }, - { - 'lnsDragDrop-isReorderable': draggingProps.isReorderDragging, + reorderedItems: reorderableGroup.slice(draggingIndex + 1, droppingIndex + 1), + direction: '-', } - ), - style: reorderedItems.includes(id) - ? { - transform: `translateY(${direction}${draggingHeight}px)`, - } - : {}, - })} + : { + ...s, + reorderedItems: reorderableGroup.slice(droppingIndex, draggingIndex), + direction: '+', + } + ); + }; + + const onReorderableDrop = (e: DroppableEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setActiveDropTarget(undefined); + setDragging(undefined); + setKeyboardMode(false); + + if (onDrop && droppable && dragging) { + trackUiEvent('drop_total'); + + onDrop(dragging, value); + const draggingIndex = reorderableGroup.findIndex((i) => i.id === dragging.id); + // setTimeout ensures it will run after dragEnd messaging + setTimeout(() => + setA11yMessage(reorderAnnouncements.dropped(currentIndex + 1, draggingIndex + 1)) + ); + } + }; + + return ( +
i.id === value.id) + ? { + transform: `translateY(${direction}${draggingHeight}px)`, + } + : undefined + } + ref={heightRef} + data-test-subj="lnsDragDrop-translatableDrop" + className="lnsDragDrop-translatableDrop lnsDragDrop-reorderable" + > + +
+ +
{ - dropProps.onDrop(e); - setReorderState((s: ReorderState) => ({ - ...s, - reorderedItems: [], - })); - }} - onDragOver={(e: DroppableEvent) => { - if (!droppable) { - return; - } - dropProps.onDragOver(e); - if (!dropProps.isActive) { - if (!dragging) { - return; - } - const draggingIndex = itemsInGroup.indexOf(dragging.id); - const droppingIndex = currentIndex; - if (draggingIndex === droppingIndex) { - setReorderState((s: ReorderState) => ({ - ...s, - reorderedItems: [], - })); - } - - setReorderState((s: ReorderState) => - draggingIndex < droppingIndex - ? { - ...s, - reorderedItems: itemsInGroup.slice(draggingIndex + 1, droppingIndex + 1), - direction: '-', - } - : { - ...s, - reorderedItems: itemsInGroup.slice(droppingIndex, draggingIndex), - direction: '+', - } - ); - } - }} + onDrop={onReorderableDrop} + onDragOver={onReorderableDragOver} onDragLeave={() => { - dropProps.onDragLeave(); setReorderState((s: ReorderState) => ({ ...s, reorderedItems: [], @@ -527,4 +719,4 @@ export const ReorderableDragDrop = ({ />
); -}; +}); diff --git a/x-pack/plugins/lens/public/drag_drop/providers.tsx b/x-pack/plugins/lens/public/drag_drop/providers.tsx index 5e0fc648454ad..86ff5054520af 100644 --- a/x-pack/plugins/lens/public/drag_drop/providers.tsx +++ b/x-pack/plugins/lens/public/drag_drop/providers.tsx @@ -9,12 +9,13 @@ import classNames from 'classnames'; import { EuiScreenReaderOnly, EuiPortal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -export type Dragging = - | (Record & { - id: string; - }) - | undefined; +export type DragDropIdentifier = Record & { + id: string; +}; +export interface ActiveDropTarget { + activeDropTarget?: DragDropIdentifier; +} /** * The shape of the drag / drop context. */ @@ -22,12 +23,26 @@ export interface DragContextState { /** * The item being dragged or undefined. */ - dragging: Dragging; + dragging?: DragDropIdentifier; + /** + * keyboard mode + */ + keyboardMode: boolean; + /** + * keyboard mode + */ + setKeyboardMode: (mode: boolean) => void; /** * Set the item being dragged. */ - setDragging: (dragging: Dragging) => void; + setDragging: (dragging?: DragDropIdentifier) => void; + + activeDropTarget?: ActiveDropTarget; + + setActiveDropTarget: (newTarget?: DragDropIdentifier) => void; + + setA11yMessage: (message: string) => void; } /** @@ -38,28 +53,52 @@ export interface DragContextState { export const DragContext = React.createContext({ dragging: undefined, setDragging: () => {}, + keyboardMode: false, + setKeyboardMode: () => {}, + activeDropTarget: undefined, + setActiveDropTarget: () => {}, + setA11yMessage: () => {}, }); /** * The argument to DragDropProvider. */ export interface ProviderProps { + /** + * keyboard mode + */ + keyboardMode: boolean; + /** + * keyboard mode + */ + setKeyboardMode: (mode: boolean) => void; + /** + * Set the item being dragged. + */ /** * The item being dragged. If unspecified, the provider will * behave as if it is the root provider. */ - dragging: Dragging; + dragging?: DragDropIdentifier; /** * Sets the item being dragged. If unspecified, the provider * will behave as if it is the root provider. */ - setDragging: (dragging: Dragging) => void; + setDragging: (dragging?: DragDropIdentifier) => void; + + activeDropTarget?: { + activeDropTarget?: DragDropIdentifier; + }; + + setActiveDropTarget: (newTarget?: DragDropIdentifier) => void; /** * The React children. */ children: React.ReactNode; + + setA11yMessage: (message: string) => void; } /** @@ -70,15 +109,60 @@ export interface ProviderProps { * @param props */ export function RootDragDropProvider({ children }: { children: React.ReactNode }) { - const [state, setState] = useState<{ dragging: Dragging }>({ + const [draggingState, setDraggingState] = useState<{ dragging?: DragDropIdentifier }>({ dragging: undefined, }); - const setDragging = useMemo(() => (dragging: Dragging) => setState({ dragging }), [setState]); + const [keyboardModeState, setKeyboardModeState] = useState(false); + const [a11yMessageState, setA11yMessageState] = useState(''); + const [activeDropTargetState, setActiveDropTargetState] = useState<{ + activeDropTarget?: DragDropIdentifier; + }>({ + activeDropTarget: undefined, + }); + + const setDragging = useMemo( + () => (dragging?: DragDropIdentifier) => setDraggingState({ dragging }), + [setDraggingState] + ); + + const setA11yMessage = useMemo(() => (message: string) => setA11yMessageState(message), [ + setA11yMessageState, + ]); + + const setActiveDropTarget = useMemo( + () => (activeDropTarget?: DragDropIdentifier) => + setActiveDropTargetState((s) => ({ ...s, activeDropTarget })), + [setActiveDropTargetState] + ); return ( - - {children} - +
+ + {children} + + + +
+

+ {a11yMessageState} +

+

+ {i18n.translate('xpack.lens.dragDrop.keyboardInstructions', { + defaultMessage: `Press enter or space to start reordering the dimension group. When dragging, use arrow keys to reorder. Press enter or space again to finish.`, + })} +

+
+
+
+
); } @@ -89,8 +173,36 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode } * * @param props */ -export function ChildDragDropProvider({ dragging, setDragging, children }: ProviderProps) { - const value = useMemo(() => ({ dragging, setDragging }), [setDragging, dragging]); +export function ChildDragDropProvider({ + dragging, + setDragging, + setKeyboardMode, + keyboardMode, + activeDropTarget, + setActiveDropTarget, + setA11yMessage, + children, +}: ProviderProps) { + const value = useMemo( + () => ({ + setKeyboardMode, + keyboardMode, + dragging, + setDragging, + activeDropTarget, + setActiveDropTarget, + setA11yMessage, + }), + [ + setDragging, + dragging, + activeDropTarget, + setActiveDropTarget, + setKeyboardMode, + keyboardMode, + setA11yMessage, + ] + ); return {children}; } @@ -98,7 +210,7 @@ export interface ReorderState { /** * Ids of the elements that are translated up or down */ - reorderedItems: string[]; + reorderedItems: DragDropIdentifier[]; /** * Direction of the move of dragged element in the reordered list @@ -112,10 +224,6 @@ export interface ReorderState { * indicates that user is in keyboard mode */ isReorderOn: boolean; - /** - * aria-live message for changes in reordering - */ - keyboardReorderMessage: string; /** * reorder group needed for screen reader aria-described-by attribute */ @@ -135,7 +243,6 @@ export const ReorderContext = React.createContext({ direction: '-', draggingHeight: 40, isReorderOn: false, - keyboardReorderMessage: '', groupId: '', }, setReorderState: () => () => {}, @@ -155,33 +262,70 @@ export function ReorderProvider({ direction: '-', draggingHeight: 40, isReorderOn: false, - keyboardReorderMessage: '', groupId: id, }); const setReorderState = useMemo(() => (dispatch: SetReorderStateDispatch) => setState(dispatch), [ setState, ]); - return ( -
+
1, + })} + > {children} - - -
-

- {state.keyboardReorderMessage} -

-

- {i18n.translate('xpack.lens.dragDrop.reorderInstructions', { - defaultMessage: `Press space bar to start a drag. When dragging, use arrow keys to reorder. Press space bar again to finish.`, - })} -

-
-
-
); } + +export const reorderAnnouncements = { + moved: (itemLabel: string, position: number, prevPosition: number) => { + return prevPosition === position + ? i18n.translate('xpack.lens.dragDrop.elementMovedBack', { + defaultMessage: `You have moved back the item {itemLabel} to position {prevPosition}`, + values: { + itemLabel, + prevPosition, + }, + }) + : i18n.translate('xpack.lens.dragDrop.elementMoved', { + defaultMessage: `You have moved the item {itemLabel} from position {prevPosition} to position {position}`, + values: { + itemLabel, + position, + prevPosition, + }, + }); + }, + + lifted: (itemLabel: string, position: number) => + i18n.translate('xpack.lens.dragDrop.elementLifted', { + defaultMessage: `You have lifted an item {itemLabel} in position {position}`, + values: { + itemLabel, + position, + }, + }), + + cancelled: (position: number) => + i18n.translate('xpack.lens.dragDrop.abortMessageReorder', { + defaultMessage: + 'Movement cancelled. The item has returned to its starting position {position}', + values: { + position, + }, + }), + dropped: (position: number, prevPosition: number) => + i18n.translate('xpack.lens.dragDrop.dropMessageReorder', { + defaultMessage: + 'You have dropped the item. You have moved the item from position {prevPosition} to positon {position}', + values: { + position, + prevPosition, + }, + }), +}; diff --git a/x-pack/plugins/lens/public/drag_drop/readme.md b/x-pack/plugins/lens/public/drag_drop/readme.md index 1e812c7adac27..e48564a074986 100644 --- a/x-pack/plugins/lens/public/drag_drop/readme.md +++ b/x-pack/plugins/lens/public/drag_drop/readme.md @@ -30,7 +30,7 @@ In your child application, place a `ChildDragDropProvider` at the root of that, This enables your child application to share the same drag / drop context as the root application. -## Dragging +## DragDropIdentifier An item can be both draggable and droppable at the same time, but for simplicity's sake, we'll treat these two cases separately. @@ -88,7 +88,7 @@ The children `DragDrop` components must have props defined as in the example: droppable dragType="reorder" dropType="reorder" - itemsInGroup={fields.map((f) => f.id)} // consists ids of all reorderable elements in the group, eg. ['3', '5', '1'] + reorderableGroup={fields} // consists all reorderable elements in the group, eg. [{id:'3'}, {id:'5'}, {id:'1'}] value={{ id: f.id, }} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx index 70c4fb5567226..0ebcb5bb07482 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx @@ -92,6 +92,14 @@ describe('ConfigPanel', () => { mockDatasource = createMockDatasource('ds1'); }); + // in what case is this test needed? + it('should fail to render layerPanels if the public API is out of date', () => { + const props = getDefaultProps(); + props.framePublicAPI.datasourceLayers = {}; + const component = mountWithIntl(); + expect(component.find(LayerPanel).exists()).toBe(false); + }); + describe('focus behavior when adding or removing layers', () => { it('should focus the only layer when resetting the layer', () => { const component = mountWithIntl(); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index ec1a5c226d351..67c8a6b5e4abc 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -134,37 +134,42 @@ export function LayerPanels( [dispatch] ); + const datasourcePublicAPIs = props.framePublicAPI.datasourceLayers; + return ( - {layerIds.map((layerId, index) => ( - { - dispatch({ - type: 'UPDATE_STATE', - subType: 'REMOVE_OR_CLEAR_LAYER', - updater: (state) => - removeLayer({ - activeVisualization, - layerId, - trackUiEvent, - datasourceMap, - state, - }), - }); - removeLayerRef(layerId); - }} - /> - ))} + {layerIds.map((layerId, layerIndex) => + datasourcePublicAPIs[layerId] ? ( + { + dispatch({ + type: 'UPDATE_STATE', + subType: 'REMOVE_OR_CLEAR_LAYER', + updater: (state) => + removeLayer({ + activeVisualization, + layerId, + trackUiEvent, + datasourceMap, + state, + }), + }); + removeLayerRef(layerId); + }} + /> + ) : null + )} {activeVisualization.appendLayer && visualizationState && ( + i18n.translate('xpack.lens.configure.editConfig', { + defaultMessage: 'Edit {label} configuration', + values: { label }, + }); + +export function DimensionButton({ + group, + children, + onClick, + onRemoveClick, + accessorConfig, + label, +}: { + group: VisualizationDimensionGroupConfig; + children: React.ReactElement; + onClick: (id: string) => void; + onRemoveClick: (id: string) => void; + accessorConfig: AccessorConfig; + label: string; +}) { + return ( + <> + onClick(accessorConfig.columnId)} + aria-label={triggerLinkA11yText(label)} + title={triggerLinkA11yText(label)} + > + {children} + + onRemoveClick(accessorConfig.columnId)} + /> + + + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx new file mode 100644 index 0000000000000..8de57cb43b16f --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { DragDrop, DragDropIdentifier, DragContextState } from '../../../drag_drop'; +import { Datasource, VisualizationDimensionGroupConfig, isDraggedOperation } from '../../../types'; +import { LayerDatasourceDropProps } from './types'; + +const isFromTheSameGroup = (el1: DragDropIdentifier, el2?: DragDropIdentifier) => + el2 && isDraggedOperation(el2) && el1.groupId === el2.groupId && el1.columnId !== el2.columnId; + +const isSelf = (el1: DragDropIdentifier, el2?: DragDropIdentifier) => + isDraggedOperation(el2) && el1.columnId === el2.columnId; + +export function DraggableDimensionButton({ + layerId, + label, + accessorIndex, + groupIndex, + layerIndex, + columnId, + group, + onDrop, + children, + dragDropContext, + layerDatasourceDropProps, + layerDatasource, +}: { + dragDropContext: DragContextState; + layerId: string; + groupIndex: number; + layerIndex: number; + onDrop: (droppedItem: DragDropIdentifier, dropTarget: DragDropIdentifier) => void; + group: VisualizationDimensionGroupConfig; + label: string; + children: React.ReactElement; + layerDatasource: Datasource; + layerDatasourceDropProps: LayerDatasourceDropProps; + accessorIndex: number; + columnId: string; +}) { + const value = useMemo(() => { + return { + columnId, + groupId: group.groupId, + layerId, + id: columnId, + }; + }, [columnId, group.groupId, layerId]); + + const { dragging } = dragDropContext; + + const isCurrentGroup = group.groupId === dragging?.groupId; + const isOperationDragged = isDraggedOperation(dragging); + const canHandleDrop = + Boolean(dragDropContext.dragging) && + layerDatasource.canHandleDrop({ + ...layerDatasourceDropProps, + columnId, + filterOperations: group.filterOperations, + }); + + const dragType = isSelf(value, dragging) + ? 'move' + : isOperationDragged && isCurrentGroup + ? 'reorder' + : 'copy'; + + const dropType = isOperationDragged ? (!isCurrentGroup ? 'replace' : 'reorder') : 'add'; + + const isCompatibleFromOtherGroup = !isCurrentGroup && canHandleDrop; + + const isDroppable = isOperationDragged + ? dragType === 'reorder' + ? isFromTheSameGroup(value, dragging) + : isCompatibleFromOtherGroup + : canHandleDrop; + + const reorderableGroup = useMemo( + () => + group.accessors.map((a) => ({ + columnId: a.columnId, + id: a.columnId, + groupId: group.groupId, + layerId, + })), + [group, layerId] + ); + + return ( +
+ 1 ? reorderableGroup : undefined} + value={value} + label={label} + droppable={dragging && isDroppable} + onDrop={onDrop} + > + {children} + +
+ ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx new file mode 100644 index 0000000000000..88e1663d0b49c --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { generateId } from '../../../id_generator'; +import { DragDrop, DragDropIdentifier, DragContextState } from '../../../drag_drop'; +import { Datasource, VisualizationDimensionGroupConfig, isDraggedOperation } from '../../../types'; +import { LayerDatasourceDropProps } from './types'; + +export function EmptyDimensionButton({ + dragDropContext, + group, + layerDatasource, + layerDatasourceDropProps, + layerId, + groupIndex, + layerIndex, + onClick, + onDrop, +}: { + dragDropContext: DragContextState; + layerId: string; + groupIndex: number; + layerIndex: number; + onClick: (id: string) => void; + onDrop: (droppedItem: DragDropIdentifier, dropTarget: DragDropIdentifier) => void; + group: VisualizationDimensionGroupConfig; + + layerDatasource: Datasource; + layerDatasourceDropProps: LayerDatasourceDropProps; +}) { + const handleDrop = (droppedItem: DragDropIdentifier) => onDrop(droppedItem, value); + + const value = useMemo(() => { + const newId = generateId(); + return { + columnId: newId, + groupId: group.groupId, + layerId, + isNew: true, + id: newId, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [group.accessors.length, group.groupId, layerId]); + + return ( +
+ +
+ { + onClick(value.columnId); + }} + > + + +
+
+
+ ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss index 2ed91b962ff11..ec4c2adba8fd7 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss @@ -76,6 +76,7 @@ .lnsLayerPanel__dimensionContainer { margin: 0 $euiSizeS $euiSizeS; + position: relative; &:last-child { margin-bottom: 0; @@ -127,12 +128,13 @@ } } -.lnsLayerPanel__dimensionLink { +// Added .lnsLayerPanel__dimension specificity required for animation style override +.lnsLayerPanel__dimension .lnsLayerPanel__dimensionLink { width: 100%; &:focus { - background-color: transparent !important; // sass-lint:disable-line no-important - outline: none !important; // sass-lint:disable-line no-important + background-color: transparent; + animation: none !important; // sass-lint:disable-line no-important } &:focus .lnsLayerPanel__triggerTextLabel, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index cab07150b6d56..d93cbbb58835e 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -12,7 +12,7 @@ import { createMockDatasource, DatasourceMock, } from '../../mocks'; -import { ChildDragDropProvider, DroppableEvent } from '../../../drag_drop'; +import { ChildDragDropProvider, DroppableEvent, DragDrop } from '../../../drag_drop'; import { EuiFormRow } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test/jest'; import { Visualization } from '../../../types'; @@ -22,9 +22,20 @@ import { generateId } from '../../../id_generator'; jest.mock('../../../id_generator'); +const defaultContext = { + dragging: undefined, + setDragging: jest.fn(), + setActiveDropTarget: () => {}, + activeDropTarget: undefined, + keyboardMode: false, + setKeyboardMode: () => {}, + setA11yMessage: jest.fn(), +}; + describe('LayerPanel', () => { let mockVisualization: jest.Mocked; let mockVisualization2: jest.Mocked; + let mockDatasource: DatasourceMock; function getDefaultProps() { @@ -34,11 +45,7 @@ describe('LayerPanel', () => { }; return { layerId: 'first', - activeVisualizationId: 'vis1', - visualizationMap: { - vis1: mockVisualization, - vis2: mockVisualization2, - }, + activeVisualization: mockVisualization, activeDatasourceId: 'ds1', datasourceMap: { ds1: mockDatasource, @@ -58,7 +65,7 @@ describe('LayerPanel', () => { onRemoveLayer: jest.fn(), dispatch: jest.fn(), core: coreMock.createStart(), - index: 0, + layerIndex: 0, setLayerRef: jest.fn(), }; } @@ -92,20 +99,6 @@ describe('LayerPanel', () => { mockDatasource = createMockDatasource('ds1'); }); - it('should fail to render if the public API is out of date', () => { - const props = getDefaultProps(); - props.framePublicAPI.datasourceLayers = {}; - const component = mountWithIntl(); - expect(component.isEmptyRender()).toBe(true); - }); - - it('should fail to render if the active visualization is missing', () => { - const component = mountWithIntl( - - ); - expect(component.isEmptyRender()).toBe(true); - }); - describe('layer reset and remove', () => { it('should show the reset button when single layer', () => { const component = mountWithIntl(); @@ -147,8 +140,7 @@ describe('LayerPanel', () => { }); const component = mountWithIntl(); - - const group = component.find('DragDrop[data-test-subj="lnsGroup"]'); + const group = component.find('.lnsLayerPanel__dimensionContainer[data-test-subj="lnsGroup"]'); expect(group).toHaveLength(1); }); @@ -167,8 +159,7 @@ describe('LayerPanel', () => { }); const component = mountWithIntl(); - - const group = component.find('DragDrop[data-test-subj="lnsGroup"]'); + const group = component.find('.lnsLayerPanel__dimensionContainer[data-test-subj="lnsGroup"]'); expect(group).toHaveLength(1); }); @@ -231,50 +222,6 @@ describe('LayerPanel', () => { expect(panel.props.children).toHaveLength(2); }); - it('should keep the DimensionContainer open when configuring a new dimension', () => { - /** - * The ID generation system for new dimensions has been messy before, so - * this tests that the ID used in the first render is used to keep the container - * open in future renders - */ - (generateId as jest.Mock).mockReturnValueOnce(`newid`); - (generateId as jest.Mock).mockReturnValueOnce(`bad`); - mockVisualization.getConfiguration.mockReturnValueOnce({ - groups: [ - { - groupLabel: 'A', - groupId: 'a', - accessors: [], - filterOperations: () => true, - supportsMoreColumns: true, - dataTestSubj: 'lnsGroup', - }, - ], - }); - // Normally the configuration would change in response to a state update, - // but this test is updating it directly - mockVisualization.getConfiguration.mockReturnValueOnce({ - groups: [ - { - groupLabel: 'A', - groupId: 'a', - accessors: [{ columnId: 'newid' }], - filterOperations: () => true, - supportsMoreColumns: false, - dataTestSubj: 'lnsGroup', - }, - ], - }); - - const component = mountWithIntl(); - act(() => { - component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); - }); - component.update(); - - expect(component.find('EuiFlyoutHeader').exists()).toBe(true); - }); - it('should not update the visualization if the datasource is incomplete', () => { (generateId as jest.Mock).mockReturnValueOnce(`newid`); const updateAll = jest.fn(); @@ -338,6 +285,50 @@ describe('LayerPanel', () => { expect(updateAll).toHaveBeenCalled(); }); + it('should keep the DimensionContainer open when configuring a new dimension', () => { + /** + * The ID generation system for new dimensions has been messy before, so + * this tests that the ID used in the first render is used to keep the container + * open in future renders + */ + (generateId as jest.Mock).mockReturnValueOnce(`newid`); + (generateId as jest.Mock).mockReturnValueOnce(`bad`); + mockVisualization.getConfiguration.mockReturnValueOnce({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup', + }, + ], + }); + // Normally the configuration would change in response to a state update, + // but this test is updating it directly + mockVisualization.getConfiguration.mockReturnValueOnce({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'newid' }], + filterOperations: () => true, + supportsMoreColumns: false, + dataTestSubj: 'lnsGroup', + }, + ], + }); + + const component = mountWithIntl(); + act(() => { + component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); + }); + component.update(); + + expect(component.find('EuiFlyoutHeader').exists()).toBe(true); + }); + it('should close the DimensionContainer when the active visualization changes', () => { /** * The ID generation system for new dimensions has been messy before, so @@ -361,7 +352,7 @@ describe('LayerPanel', () => { }); // Normally the configuration would change in response to a state update, // but this test is updating it directly - mockVisualization.getConfiguration.mockReturnValueOnce({ + mockVisualization.getConfiguration.mockReturnValue({ groups: [ { groupLabel: 'A', @@ -382,7 +373,7 @@ describe('LayerPanel', () => { component.update(); expect(component.find('EuiFlyoutHeader').exists()).toBe(true); act(() => { - component.setProps({ activeVisualizationId: 'vis2' }); + component.setProps({ activeVisualization: mockVisualization2 }); }); component.update(); expect(component.find('EuiFlyoutHeader').exists()).toBe(false); @@ -452,7 +443,7 @@ describe('LayerPanel', () => { const draggingField = { field: { name: 'dragged' }, indexPatternId: 'a', id: '1' }; const component = mountWithIntl( - + ); @@ -465,7 +456,7 @@ describe('LayerPanel', () => { }) ); - component.find('DragDrop[data-test-subj="lnsGroup"]').first().simulate('drop'); + component.find('[data-test-subj="lnsGroup"] DragDrop').first().simulate('drop'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ @@ -495,7 +486,7 @@ describe('LayerPanel', () => { const draggingField = { field: { name: 'dragged' }, indexPatternId: 'a', id: '1' }; const component = mountWithIntl( - + ); @@ -505,10 +496,14 @@ describe('LayerPanel', () => { ); expect( - component.find('DragDrop[data-test-subj="lnsGroup"]').first().prop('droppable') + component.find('[data-test-subj="lnsGroup"] DragDrop').first().prop('droppable') ).toEqual(false); - component.find('DragDrop[data-test-subj="lnsGroup"]').first().simulate('drop'); + component + .find('[data-test-subj="lnsGroup"] DragDrop') + .first() + .find('.lnsLayerPanel__dimension') + .simulate('drop'); expect(mockDatasource.onDrop).not.toHaveBeenCalled(); }); @@ -542,12 +537,11 @@ describe('LayerPanel', () => { const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a', id: 'a' }; const component = mountWithIntl( - + ); - expect(mockDatasource.canHandleDrop).toHaveBeenCalledTimes(2); expect(mockDatasource.canHandleDrop).toHaveBeenCalledWith( expect.objectContaining({ dragDropContext: expect.objectContaining({ @@ -557,7 +551,7 @@ describe('LayerPanel', () => { ); // Simulate drop on the pre-populated dimension - component.find('DragDrop[data-test-subj="lnsGroupB"]').at(0).simulate('drop'); + component.find('[data-test-subj="lnsGroupB"] DragDrop').at(0).simulate('drop'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'b', @@ -568,7 +562,7 @@ describe('LayerPanel', () => { ); // Simulate drop on the empty dimension - component.find('DragDrop[data-test-subj="lnsGroupB"]').at(1).simulate('drop'); + component.find('[data-test-subj="lnsGroupB"] DragDrop').at(1).simulate('drop'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'newid', @@ -596,18 +590,55 @@ describe('LayerPanel', () => { const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a', id: 'a' }; const component = mountWithIntl( - + + + + ); + + component.find(DragDrop).at(1).prop('onDrop')!(draggingOperation, { + layerId: 'first', + columnId: 'b', + groupId: 'a', + id: 'b', + }); + expect(mockDatasource.onDrop).toHaveBeenCalledWith( + expect.objectContaining({ + groupId: 'a', + droppedItem: draggingOperation, + }) + ); + }); + + it('should copy when dropping on empty slot in the same group', () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'a' }, { columnId: 'b' }], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup', + }, + ], + }); + + const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a', id: 'a' }; + + const component = mountWithIntl( + ); - expect(mockDatasource.canHandleDrop).not.toHaveBeenCalled(); - component.find('DragDrop[data-test-subj="lnsGroup"]').at(1).prop('onDrop')!( + component.find('[data-test-subj="lnsGroup"] DragDrop').at(2).prop('onDrop')!( (draggingOperation as unknown) as DroppableEvent ); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ - isReorder: true, + groupId: 'a', + droppedItem: draggingOperation, + isNew: true, }) ); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 999f75686b1cb..a1b13878851ee 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -5,66 +5,35 @@ */ import './layer_panel.scss'; -import React, { useContext, useState, useEffect } from 'react'; -import { - EuiPanel, - EuiSpacer, - EuiButtonIcon, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiFormRow, - EuiLink, -} from '@elastic/eui'; +import React, { useContext, useState, useEffect, useMemo, useCallback } from 'react'; +import { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; import { NativeRenderer } from '../../../native_renderer'; -import { StateSetter, isDraggedOperation } from '../../../types'; -import { DragContext, DragDrop, ChildDragDropProvider, ReorderProvider } from '../../../drag_drop'; +import { StateSetter, Visualization } from '../../../types'; +import { + DragContext, + DragDropIdentifier, + ChildDragDropProvider, + ReorderProvider, +} from '../../../drag_drop'; import { LayerSettings } from './layer_settings'; import { trackUiEvent } from '../../../lens_ui_telemetry'; -import { generateId } from '../../../id_generator'; -import { ConfigPanelWrapperProps, ActiveDimensionState } from './types'; +import { LayerPanelProps, ActiveDimensionState } from './types'; import { DimensionContainer } from './dimension_container'; -import { ColorIndicator } from './color_indicator'; -import { PaletteIndicator } from './palette_indicator'; - -const triggerLinkA11yText = (label: string) => - i18n.translate('xpack.lens.configure.editConfig', { - defaultMessage: 'Click to edit configuration for {label} or drag to move', - values: { label }, - }); +import { RemoveLayerButton } from './remove_layer_button'; +import { EmptyDimensionButton } from './empty_dimension_button'; +import { DimensionButton } from './dimension_button'; +import { DraggableDimensionButton } from './draggable_dimension_button'; const initialActiveDimensionState = { isNew: false, }; -function isConfiguration( - value: unknown -): value is { columnId: string; groupId: string; layerId: string } { - return ( - Boolean(value) && - typeof value === 'object' && - 'columnId' in value! && - 'groupId' in value && - 'layerId' in value - ); -} - -function isSameConfiguration(config1: unknown, config2: unknown) { - return ( - isConfiguration(config1) && - isConfiguration(config2) && - config1.columnId === config2.columnId && - config1.groupId === config2.groupId && - config1.layerId === config2.layerId - ); -} - export function LayerPanel( - props: Exclude & { + props: Exclude & { + activeVisualization: Visualization; layerId: string; - index: number; + layerIndex: number; isOnlyLayer: boolean; updateVisualization: StateSetter; updateDatasource: (datasourceId: string, newState: unknown) => void; @@ -82,26 +51,25 @@ export function LayerPanel( initialActiveDimensionState ); - const { framePublicAPI, layerId, isOnlyLayer, onRemoveLayer, setLayerRef, index } = props; + const { + framePublicAPI, + layerId, + isOnlyLayer, + onRemoveLayer, + setLayerRef, + layerIndex, + activeVisualization, + updateVisualization, + updateDatasource, + } = props; const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId]; useEffect(() => { setActiveDimension(initialActiveDimensionState); - }, [props.activeVisualizationId]); + }, [activeVisualization.id]); - const setLayerRefMemoized = React.useCallback((el) => setLayerRef(layerId, el), [ - layerId, - setLayerRef, - ]); + const setLayerRefMemoized = useCallback((el) => setLayerRef(layerId, el), [layerId, setLayerRef]); - if ( - !datasourcePublicAPI || - !props.activeVisualizationId || - !props.visualizationMap[props.activeVisualizationId] - ) { - return null; - } - const activeVisualization = props.visualizationMap[props.activeVisualizationId]; const layerVisualizationConfigProps = { layerId, dragDropContext, @@ -110,18 +78,23 @@ export function LayerPanel( dateRange: props.framePublicAPI.dateRange, activeData: props.framePublicAPI.activeData, }; + const datasourceId = datasourcePublicAPI.datasourceId; const layerDatasourceState = props.datasourceStates[datasourceId].state; - const layerDatasource = props.datasourceMap[datasourceId]; - const layerDatasourceDropProps = { - layerId, - dragDropContext, - state: layerDatasourceState, - setState: (newState: unknown) => { - props.updateDatasource(datasourceId, newState); - }, - }; + const layerDatasourceDropProps = useMemo( + () => ({ + layerId, + dragDropContext, + state: layerDatasourceState, + setState: (newState: unknown) => { + updateDatasource(datasourceId, newState); + }, + }), + [layerId, dragDropContext, layerDatasourceState, datasourceId, updateDatasource] + ); + + const layerDatasource = props.datasourceMap[datasourceId]; const layerDatasourceConfigProps = { ...layerDatasourceDropProps, @@ -135,10 +108,68 @@ export function LayerPanel( const { activeId, activeGroup } = activeDimension; const columnLabelMap = layerDatasource.uniqueLabels(layerDatasourceConfigProps.state); + + const { setDimension, removeDimension } = activeVisualization; + const layerDatasourceOnDrop = layerDatasource.onDrop; + + const onDrop = useMemo(() => { + return (droppedItem: DragDropIdentifier, targetItem: DragDropIdentifier) => { + const { columnId, groupId, layerId: targetLayerId, isNew } = (targetItem as unknown) as { + groupId: string; + columnId: string; + layerId: string; + isNew?: boolean; + }; + + const filterOperations = + groups.find(({ groupId: gId }) => gId === targetItem.groupId)?.filterOperations || + (() => false); + + const dropResult = layerDatasourceOnDrop({ + ...layerDatasourceDropProps, + droppedItem, + columnId, + groupId, + layerId: targetLayerId, + isNew, + filterOperations, + }); + if (dropResult) { + updateVisualization( + setDimension({ + columnId, + groupId, + layerId: targetLayerId, + prevState: props.visualizationState, + }) + ); + + if (typeof dropResult === 'object') { + // When a column is moved, we delete the reference to the old + updateVisualization( + removeDimension({ + columnId: dropResult.deleted, + layerId: targetLayerId, + prevState: props.visualizationState, + }) + ); + } + } + }; + }, [ + groups, + layerDatasourceOnDrop, + props.visualizationState, + updateVisualization, + setDimension, + removeDimension, + layerDatasourceDropProps, + ]); + return (
- + - {groups.map((group) => { - const newId = generateId(); + {groups.map((group, groupIndex) => { const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0; - return ( <> - - {group.accessors.map((accessorConfig) => { - const accessor = accessorConfig.columnId; - const { dragging } = dragDropContext; - const dragType = - isDraggedOperation(dragging) && accessor === dragging.columnId - ? 'move' - : isDraggedOperation(dragging) && group.groupId === dragging.groupId - ? 'reorder' - : 'copy'; - - const dropType = isDraggedOperation(dragging) - ? group.groupId !== dragging.groupId - ? 'replace' - : 'reorder' - : 'add'; - - const isFromCompatibleGroup = - dragging?.groupId !== group.groupId && - layerDatasource.canHandleDrop({ - ...layerDatasourceDropProps, - columnId: accessor, - filterOperations: group.filterOperations, - }); - - const isFromTheSameGroup = - isDraggedOperation(dragging) && - dragging.groupId === group.groupId && - dragging.columnId !== accessor; - - const isDroppable = isDraggedOperation(dragging) - ? dragType === 'reorder' - ? isFromTheSameGroup - : isFromCompatibleGroup - : layerDatasource.canHandleDrop({ - ...layerDatasourceDropProps, - columnId: accessor, - filterOperations: group.filterOperations, - }); + + {group.accessors.map((accessorConfig, accessorIndex) => { + const { columnId } = accessorConfig; return ( - - typeof a === 'string' ? a : a.columnId - )} - className={'lnsLayerPanel__dimensionContainer'} - value={{ - columnId: accessor, - groupId: group.groupId, - layerId, - id: accessor, - }} - isValueEqual={isSameConfiguration} - label={columnLabelMap[accessor]} - droppable={dragging && isDroppable} - dropTo={(dropTargetId: string) => { - layerDatasource.onDrop({ - isReorder: true, - ...layerDatasourceDropProps, - droppedItem: { - columnId: accessor, - groupId: group.groupId, - layerId, - id: accessor, - }, - columnId: dropTargetId, - filterOperations: group.filterOperations, - }); - }} - onDrop={(droppedItem) => { - const isReorder = - isDraggedOperation(droppedItem) && - droppedItem.groupId === group.groupId && - droppedItem.columnId !== accessor; - - const dropResult = layerDatasource.onDrop({ - isReorder, - ...layerDatasourceDropProps, - droppedItem, - columnId: accessor, - filterOperations: group.filterOperations, - }); - if (typeof dropResult === 'object') { - // When a column is moved, we delete the reference to the old - props.updateVisualization( - activeVisualization.removeDimension({ - layerId, - columnId: dropResult.deleted, - prevState: props.visualizationState, - }) - ); - } - }} +
- { - if (activeId) { - setActiveDimension(initialActiveDimensionState); - } else { - setActiveDimension({ - isNew: false, - activeGroup: group, - activeId: accessor, - }); - } + { + setActiveDimension({ + isNew: false, + activeGroup: group, + activeId: id, + }); }} - aria-label={triggerLinkA11yText(columnLabelMap[accessor])} - title={triggerLinkA11yText(columnLabelMap[accessor])} - > - - - - - { + onRemoveClick={(id: string) => { trackUiEvent('indexpattern_dimension_removed'); props.updateAll( datasourceId, layerDatasource.removeColumn({ layerId, - columnId: accessor, + columnId: id, prevState: layerDatasourceState, }), activeVisualization.removeDimension({ layerId, - columnId: accessor, + columnId: id, prevState: props.visualizationState, }) ); }} - /> - + > + +
-
+ ); })}
{group.supportsMoreColumns ? ( -
- { - const dropResult = layerDatasource.onDrop({ - ...layerDatasourceDropProps, - droppedItem, - columnId: newId, - filterOperations: group.filterOperations, - }); - if (dropResult) { - props.updateVisualization( - activeVisualization.setDimension({ - layerId, - groupId: group.groupId, - columnId: newId, - prevState: props.visualizationState, - }) - ); - - if (typeof dropResult === 'object') { - // When a column is moved, we delete the reference to the old - props.updateVisualization( - activeVisualization.removeDimension({ - layerId, - columnId: dropResult.deleted, - prevState: props.visualizationState, - }) - ); - } - } - }} - > -
- { - if (activeId) { - setActiveDimension(initialActiveDimensionState); - } else { - setActiveDimension({ - isNew: true, - activeGroup: group, - activeId: newId, - }); - } - }} - > - - -
-
-
+ { + setActiveDimension({ + activeGroup: group, + activeId: id, + isNew: true, + }); + }} + onDrop={onDrop} + /> ) : null}
@@ -572,44 +426,11 @@ export function LayerPanel( - { - // If we don't blur the remove / clear button, it remains focused - // which is a strange UX in this case. e.target.blur doesn't work - // due to who knows what, but probably event re-writing. Additionally, - // activeElement does not have blur so, we need to do some casting + safeguards. - const el = (document.activeElement as unknown) as { blur: () => void }; - - if (el?.blur) { - el.blur(); - } - - onRemoveLayer(); - }} - > - {isOnlyLayer - ? i18n.translate('xpack.lens.resetLayer', { - defaultMessage: 'Reset layer', - }) - : i18n.translate('xpack.lens.deleteLayer', { - defaultMessage: `Delete layer`, - })} - +
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx new file mode 100644 index 0000000000000..526e2fcefe19d --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export function RemoveLayerButton({ + onRemoveLayer, + layerIndex, + isOnlyLayer, +}: { + onRemoveLayer: () => void; + layerIndex: number; + isOnlyLayer: boolean; +}) { + return ( + { + // If we don't blur the remove / clear button, it remains focused + // which is a strange UX in this case. e.target.blur doesn't work + // due to who knows what, but probably event re-writing. Additionally, + // activeElement does not have blur so, we need to do some casting + safeguards. + const el = (document.activeElement as unknown) as { blur: () => void }; + + if (el?.blur) { + el.blur(); + } + + onRemoveLayer(); + }} + > + {isOnlyLayer + ? i18n.translate('xpack.lens.resetLayer', { + defaultMessage: 'Reset layer', + }) + : i18n.translate('xpack.lens.deleteLayer', { + defaultMessage: `Delete layer`, + })} + + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts index c172c6da6848c..0a53fc741c207 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts @@ -12,7 +12,7 @@ import { DatasourceDimensionEditorProps, VisualizationDimensionGroupConfig, } from '../../../types'; - +import { DragContextState } from '../../../drag_drop'; export interface ConfigPanelWrapperProps { activeDatasourceId: string; visualizationState: unknown; @@ -31,6 +31,30 @@ export interface ConfigPanelWrapperProps { core: DatasourceDimensionEditorProps['core']; } +export interface LayerPanelProps { + activeDatasourceId: string; + visualizationState: unknown; + datasourceMap: Record; + activeVisualization: Visualization; + dispatch: (action: Action) => void; + framePublicAPI: FramePublicAPI; + datasourceStates: Record< + string, + { + isLoading: boolean; + state: unknown; + } + >; + core: DatasourceDimensionEditorProps['core']; +} + +export interface LayerDatasourceDropProps { + layerId: string; + dragDropContext: DragContextState; + state: unknown; + setState: (newState: unknown) => void; +} + export interface ActiveDimensionState { isNew: boolean; activeId?: string; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx index 69bdff0151f6c..c45dc82a3aeb2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { EuiPopover, EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; import { NativeRenderer } from '../../native_renderer'; import { Action } from './state_management'; -import { DragContext, Dragging } from '../../drag_drop'; +import { DragContext, DragDropIdentifier } from '../../drag_drop'; import { StateSetter, FramePublicAPI, DatasourceDataPanelProps, Datasource } from '../../types'; import { Query, Filter } from '../../../../../../src/plugins/data/public'; @@ -26,8 +26,8 @@ interface DataPanelWrapperProps { query: Query; dateRange: FramePublicAPI['dateRange']; filters: Filter[]; - dropOntoWorkspace: (field: Dragging) => void; - hasSuggestionForField: (field: Dragging) => boolean; + dropOntoWorkspace: (field: DragDropIdentifier) => void; + hasSuggestionForField: (field: DragDropIdentifier) => boolean; } export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index c0728bd030a0a..7daf1ebb17b97 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -1338,10 +1338,14 @@ describe('editor_frame', () => { instance.update(); act(() => { - instance.find(DragDrop).filter('[data-test-subj="mockVisA"]').prop('onDrop')!({ - indexPatternId: '1', - field: {}, - }); + instance.find('[data-test-subj="mockVisA"]').find(DragDrop).prop('onDrop')!( + { + indexPatternId: '1', + field: {}, + id: '1', + }, + { id: 'lnsWorkspace' } + ); }); expect(mockVisualization2.getConfiguration).toHaveBeenCalledWith( @@ -1435,10 +1439,14 @@ describe('editor_frame', () => { instance.update(); act(() => { - instance.find(DragDrop).filter('[data-test-subj="lnsWorkspace"]').prop('onDrop')!({ - indexPatternId: '1', - field: {}, - }); + instance.find(DragDrop).filter('[dataTestSubj="lnsWorkspace"]').prop('onDrop')!( + { + indexPatternId: '1', + field: {}, + id: '1', + }, + { id: 'lnsWorkspace' } + ); }); expect(mockVisualization3.getConfiguration).toHaveBeenCalledWith( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index b6df0caa07577..c3412c32c2184 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useReducer, useState } from 'react'; +import React, { useEffect, useReducer, useState, useCallback } from 'react'; import { CoreSetup, CoreStart } from 'kibana/public'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; @@ -16,7 +16,7 @@ import { FrameLayout } from './frame_layout'; import { SuggestionPanel } from './suggestion_panel'; import { WorkspacePanel } from './workspace_panel'; import { Document } from '../../persistence/saved_object_store'; -import { Dragging, RootDragDropProvider } from '../../drag_drop'; +import { DragDropIdentifier, RootDragDropProvider } from '../../drag_drop'; import { getSavedObjectFormat } from './save'; import { generateId } from '../../id_generator'; import { Filter, Query, SavedQuery } from '../../../../../../src/plugins/data/public'; @@ -260,7 +260,7 @@ export function EditorFrame(props: EditorFrameProps) { ); const getSuggestionForField = React.useCallback( - (field: Dragging) => { + (field: DragDropIdentifier) => { const { activeDatasourceId, datasourceStates } = state; const activeVisualizationId = state.visualization.activeId; const visualizationState = state.visualization.state; @@ -290,12 +290,12 @@ export function EditorFrame(props: EditorFrameProps) { ] ); - const hasSuggestionForField = React.useCallback( - (field: Dragging) => getSuggestionForField(field) !== undefined, + const hasSuggestionForField = useCallback( + (field: DragDropIdentifier) => getSuggestionForField(field) !== undefined, [getSuggestionForField] ); - const dropOntoWorkspace = React.useCallback( + const dropOntoWorkspace = useCallback( (field) => { const suggestion = getSuggestionForField(field); if (suggestion) { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts index 5cdc5ce592497..95dbf8264c588 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts @@ -19,7 +19,7 @@ import { DatasourcePublicAPI, } from '../../types'; import { Action } from './state_management'; -import { Dragging } from '../../drag_drop'; +import { DragDropIdentifier } from '../../drag_drop'; export interface Suggestion { visualizationId: string; @@ -231,7 +231,7 @@ export function getTopSuggestionForField( visualizationState: unknown, datasource: Datasource, datasourceStates: Record, - field: Dragging + field: DragDropIdentifier ) { const hasData = Object.values(datasourceLayers).some( (datasourceLayer) => datasourceLayer.getTableSpec().length > 0 diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx index ddb2640d50d59..2f94d8e65dce6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx @@ -784,7 +784,15 @@ describe('workspace_panel', () => { function initComponent(draggingContext = draggedField) { instance = mount( - {}}> + {}} + setActiveDropTarget={() => {}} + activeDropTarget={undefined} + keyboardMode={false} + setKeyboardMode={() => {}} + setA11yMessage={() => {}} + > { }); initComponent(); - instance.find(DragDrop).prop('onDrop')!(draggedField); + instance.find(DragDrop).prop('onDrop')!(draggedField, { id: 'lnsWorkspace' }); expect(mockDispatch).toHaveBeenCalledWith({ type: 'SWITCH_VISUALIZATION', diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 5fc7b80a3d0ce..0c1fa932da09c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -39,7 +39,7 @@ import { isLensFilterEvent, isLensEditEvent, } from '../../../types'; -import { DragDrop, DragContext, Dragging } from '../../../drag_drop'; +import { DragDrop, DragContext, DragDropIdentifier } from '../../../drag_drop'; import { Suggestion, switchToSuggestion } from '../suggestion_helpers'; import { buildExpression } from '../expression_helpers'; import { debouncedComponent } from '../../../debounced_component'; @@ -75,7 +75,7 @@ export interface WorkspacePanelProps { plugins: { uiActions?: UiActionsStart; data: DataPublicPluginStart }; title?: string; visualizeTriggerFieldContext?: VisualizeFieldContext; - getSuggestionForField: (field: Dragging) => Suggestion | undefined; + getSuggestionForField: (field: DragDropIdentifier) => Suggestion | undefined; } interface WorkspaceState { @@ -83,8 +83,10 @@ interface WorkspaceState { expandError: boolean; } +const workspaceDropValue = { id: 'lnsWorkspace' }; + // Exported for testing purposes only. -export function WorkspacePanel({ +export const WorkspacePanel = React.memo(function WorkspacePanel({ activeDatasourceId, activeVisualizationId, visualizationMap, @@ -102,7 +104,8 @@ export function WorkspacePanel({ }: WorkspacePanelProps) { const dragDropContext = useContext(DragContext); - const suggestionForDraggedField = getSuggestionForField(dragDropContext.dragging); + const suggestionForDraggedField = + dragDropContext.dragging && getSuggestionForField(dragDropContext.dragging); const [localState, setLocalState] = useState({ expressionBuildError: undefined, @@ -296,10 +299,11 @@ export function WorkspacePanel({ >
{renderVisualization()} @@ -308,7 +312,7 @@ export function WorkspacePanel({ ); -} +}); export const InnerVisualizationWrapper = ({ expression, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index 8e41abf23e934..794ccd6936c90 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -278,7 +278,10 @@ describe('IndexPattern Data Panel', () => { {...defaultProps} state={state} setState={setStateSpy} - dragDropContext={{ dragging: { id: '1' }, setDragging: () => {} }} + dragDropContext={{ + ...createMockedDragDropContext(), + dragging: { id: '1' }, + }} /> ); @@ -297,7 +300,10 @@ describe('IndexPattern Data Panel', () => { indexPatterns: {}, }} setState={jest.fn()} - dragDropContext={{ dragging: { id: '1' }, setDragging: () => {} }} + dragDropContext={{ + ...createMockedDragDropContext(), + dragging: { id: '1' }, + }} changeIndexPattern={jest.fn()} /> ); @@ -329,7 +335,10 @@ describe('IndexPattern Data Panel', () => { ...defaultProps, changeIndexPattern: jest.fn(), setState, - dragDropContext: { dragging: { id: '1' }, setDragging: () => {} }, + dragDropContext: { + ...createMockedDragDropContext(), + dragging: { id: '1' }, + }, dateRange: { fromDate: '2019-01-01', toDate: '2020-01-01' }, state: { indexPatternRefs: [], diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 4031cae548a10..c3dbcdc3e0573 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -426,6 +426,23 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ ); }, [unfilteredFieldGroups, localState.nameFilter, localState.typeFilter]); + const checkFieldExists = useCallback( + (field) => + field.type === 'document' || + fieldExists(existingFields, currentIndexPattern.title, field.name), + [existingFields, currentIndexPattern.title] + ); + + const { nameFilter, typeFilter } = localState; + + const filter = useMemo( + () => ({ + nameFilter, + typeFilter, + }), + [nameFilter, typeFilter] + ); + const fieldProps = useMemo( () => ({ core, @@ -586,17 +603,11 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ - field.type === 'document' || - fieldExists(existingFields, currentIndexPattern.title, field.name) - } + exists={checkFieldExists} fieldProps={fieldProps} fieldGroups={fieldGroups} hasSyncedExistingFields={!!hasSyncedExistingFields} - filter={{ - nameFilter: localState.nameFilter, - typeFilter: localState.typeFilter, - }} + filter={filter} currentIndexPatternId={currentIndexPatternId} existenceFetchFailed={existenceFetchFailed} existFieldsInIndex={!!allFields.length} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts index 6be03a92a445e..477f14848c08e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts @@ -316,6 +316,7 @@ describe('IndexPatternDimensionEditorPanel', () => { droppedItem: dragging, columnId: 'col2', filterOperations: (op: OperationMetadata) => op.dataType === 'number', + groupId: '1', }); expect(setState).toBeCalledTimes(1); @@ -352,6 +353,7 @@ describe('IndexPatternDimensionEditorPanel', () => { droppedItem: dragging, columnId: 'col2', filterOperations: (op: OperationMetadata) => op.isBucketed, + groupId: '1', }); expect(setState).toBeCalledTimes(1); @@ -387,6 +389,7 @@ describe('IndexPatternDimensionEditorPanel', () => { }, droppedItem: dragging, filterOperations: (op: OperationMetadata) => op.dataType === 'number', + groupId: '1', }); expect(setState).toBeCalledTimes(1); @@ -438,6 +441,7 @@ describe('IndexPatternDimensionEditorPanel', () => { }, }, }, + groupId: '1', }); expect(setState).toBeCalledTimes(1); @@ -473,6 +477,7 @@ describe('IndexPatternDimensionEditorPanel', () => { }, droppedItem: dragging, columnId: 'col2', + groupId: '1', }); expect(setState).toBeCalledTimes(1); @@ -538,6 +543,7 @@ describe('IndexPatternDimensionEditorPanel', () => { }, droppedItem: dragging, state: testState, + groupId: '1', }); expect(setState).toBeCalledTimes(1); @@ -600,6 +606,7 @@ describe('IndexPatternDimensionEditorPanel', () => { droppedItem: dragging, state: testState, filterOperations: (op: OperationMetadata) => op.dataType === 'number', + groupId: 'a', }; const stateWithColumnOrder = (columnOrder: string[]) => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts index e4eabafc6938e..0308d5e9103bf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts @@ -8,6 +8,7 @@ import { DatasourceDimensionDropProps, DatasourceDimensionDropHandlerProps, isDraggedOperation, + DraggedOperation, } from '../../types'; import { IndexPatternColumn } from '../indexpattern'; import { insertOrReplaceColumn } from '../operations'; @@ -15,7 +16,15 @@ import { mergeLayer } from '../state_helpers'; import { hasField, isDraggedField } from '../utils'; import { IndexPatternPrivateState, IndexPatternField } from '../types'; import { trackUiEvent } from '../../lens_ui_telemetry'; -import { getOperationSupportMatrix } from './operation_support'; +import { getOperationSupportMatrix, OperationSupportMatrix } from './operation_support'; + +type DropHandlerProps = Pick< + DatasourceDimensionDropHandlerProps, + 'columnId' | 'setState' | 'state' | 'layerId' | 'droppedItem' +> & { + droppedItem: T; + operationSupportMatrix: OperationSupportMatrix; +}; export function canHandleDrop(props: DatasourceDimensionDropProps) { const operationSupportMatrix = getOperationSupportMatrix(props); @@ -29,11 +38,11 @@ export function canHandleDrop(props: DatasourceDimensionDropProps) { - const operationSupportMatrix = getOperationSupportMatrix(props); - const { setState, state, layerId, columnId, droppedItem } = props; - - if (isDraggedOperation(droppedItem) && props.isReorder) { - const dropEl = columnId; +const onReorderDrop = ({ columnId, setState, state, layerId, droppedItem }: DropHandlerProps) => { + setState( + mergeLayer({ + state, + layerId, + newLayer: { + columnOrder: reorderElements( + state.layers[layerId].columnOrder, + columnId, + droppedItem.columnId + ), + }, + }) + ); - setState( - mergeLayer({ - state, - layerId, - newLayer: { - columnOrder: reorderElements( - state.layers[layerId].columnOrder, - dropEl, - droppedItem.columnId - ), - }, - }) - ); - - return true; + return true; +}; + +const onMoveDropToCompatibleGroup = ({ + columnId, + setState, + state, + layerId, + droppedItem, +}: DropHandlerProps) => { + const layer = state.layers[layerId]; + const op = { ...layer.columns[droppedItem.columnId] }; + const newColumns = { ...layer.columns }; + delete newColumns[droppedItem.columnId]; + newColumns[columnId] = op; + + const newColumnOrder = [...layer.columnOrder]; + const oldIndex = newColumnOrder.findIndex((c) => c === droppedItem.columnId); + const newIndex = newColumnOrder.findIndex((c) => c === columnId); + + if (newIndex === -1) { + newColumnOrder[oldIndex] = columnId; + } else { + newColumnOrder.splice(oldIndex, 1); } + // Time to replace + setState( + mergeLayer({ + state, + layerId, + newLayer: { + columnOrder: newColumnOrder, + columns: newColumns, + }, + }) + ); + return { deleted: droppedItem.columnId }; +}; + +const onFieldDrop = ({ + columnId, + setState, + state, + layerId, + droppedItem, + operationSupportMatrix, +}: DropHandlerProps) => { function hasOperationForField(field: IndexPatternField) { return Boolean(operationSupportMatrix.operationByField[field.name]); } - if (isDraggedOperation(droppedItem) && droppedItem.layerId === layerId) { - const layer = state.layers[layerId]; - const op = { ...layer.columns[droppedItem.columnId] }; - if (!props.filterOperations(op)) { - return false; - } - - const newColumns = { ...layer.columns }; - delete newColumns[droppedItem.columnId]; - newColumns[columnId] = op; - - const newColumnOrder = [...layer.columnOrder]; - const oldIndex = newColumnOrder.findIndex((c) => c === droppedItem.columnId); - const newIndex = newColumnOrder.findIndex((c) => c === columnId); - - if (newIndex === -1) { - newColumnOrder[oldIndex] = columnId; - } else { - newColumnOrder.splice(oldIndex, 1); - } - - // Time to replace - setState( - mergeLayer({ - state, - layerId, - newLayer: { - columnOrder: newColumnOrder, - columns: newColumns, - }, - }) - ); - return { deleted: droppedItem.columnId }; - } - if (!isDraggedField(droppedItem) || !hasOperationForField(droppedItem.field)) { // TODO: What do we do if we couldn't find a column? return false; } + // dragged field, not operation + const operationsForNewField = operationSupportMatrix.operationByField[droppedItem.field.name]; if (!operationsForNewField || operationsForNewField.size === 0) { @@ -159,6 +174,56 @@ export function onDrop(props: DatasourceDimensionDropHandlerProps columns.length); trackUiEvent(hasData ? 'drop_non_empty' : 'drop_empty'); setState(mergeLayer({ state, layerId, newLayer })); - return true; +}; + +export function onDrop(props: DatasourceDimensionDropHandlerProps) { + const operationSupportMatrix = getOperationSupportMatrix(props); + const { setState, state, droppedItem, columnId, layerId, groupId, isNew } = props; + + if (!isDraggedOperation(droppedItem)) { + return onFieldDrop({ + columnId, + setState, + state, + layerId, + droppedItem, + operationSupportMatrix, + }); + } + const isExistingFromSameGroup = + droppedItem.groupId === groupId && droppedItem.columnId !== columnId && !isNew; + + // reorder in the same group + if (isExistingFromSameGroup) { + return onReorderDrop({ + columnId, + setState, + state, + layerId, + droppedItem, + operationSupportMatrix, + }); + } + + // replace or move to compatible group + const isFromOtherGroup = droppedItem.groupId !== groupId && droppedItem.layerId === layerId; + + if (isFromOtherGroup) { + const layer = state.layers[layerId]; + const op = { ...layer.columns[droppedItem.columnId] }; + + if (props.filterOperations(op)) { + return onMoveDropToCompatibleGroup({ + columnId, + setState, + state, + layerId, + droppedItem, + operationSupportMatrix, + }); + } + } + + return false; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx index 1019b2c33e0e5..881e7a7228762 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx @@ -95,6 +95,8 @@ describe('IndexPattern Field Item', () => { }, exists: true, chartsThemeService, + groupIndex: 0, + itemIndex: 0, dropOntoWorkspace: () => {}, hasSuggestionForField: () => false, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx index 740b557b668b7..ff335a0da56ee 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx @@ -6,7 +6,7 @@ import './field_item.scss'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useState, useMemo } from 'react'; import DateMath from '@elastic/datemath'; import { EuiButtonGroup, @@ -48,7 +48,7 @@ import { import { FieldButton } from '../../../../../src/plugins/kibana_react/public'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; import { DraggedField } from './indexpattern'; -import { DragDrop, Dragging } from '../drag_drop'; +import { DragDrop, DragDropIdentifier } from '../drag_drop'; import { DatasourceDataPanelProps, DataType } from '../types'; import { BucketedAggregation, FieldStatsResponse } from '../../common'; import { IndexPattern, IndexPatternField } from './types'; @@ -69,6 +69,8 @@ export interface FieldItemProps { chartsThemeService: ChartsPluginSetup['theme']; filters: Filter[]; hideDetails?: boolean; + itemIndex: number; + groupIndex: number; dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace']; hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField']; } @@ -106,7 +108,7 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { const [infoIsOpen, setOpen] = useState(false); const dropOntoWorkspaceAndClose = useCallback( - (droppedField: Dragging) => { + (droppedField: DragDropIdentifier) => { dropOntoWorkspace(droppedField); setOpen(false); }, @@ -163,10 +165,11 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { } } - const value = React.useMemo( + const value = useMemo( () => ({ field, indexPatternId: indexPattern.id, id: field.name } as DraggedField), [field, indexPattern.id] ); + const lensFieldIcon = ; const lensInfoIcon = ( ('.application') || undefined} button={ allFieldCount + fields.length, 0); } -export function FieldList({ +export const FieldList = React.memo(function FieldList({ exists, fieldGroups, existenceFetchFailed, @@ -135,13 +135,15 @@ export function FieldList({ {Object.entries(fieldGroups) .filter(([, { showInAccordion }]) => !showInAccordion) .flatMap(([, { fields }]) => - fields.map((field) => ( + fields.map((field, index) => ( @@ -151,7 +153,7 @@ export function FieldList({ {Object.entries(fieldGroups) .filter(([, { showInAccordion }]) => showInAccordion) - .map(([key, fieldGroup]) => ( + .map(([key, fieldGroup], index) => ( { setAccordionState((s) => ({ ...s, @@ -198,4 +201,4 @@ export function FieldList({
); -} +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.test.tsx index e2f615217bb4a..dca3de24014bc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.test.tsx @@ -72,6 +72,7 @@ describe('Fields Accordion', () => { fieldProps, renderCallout:
Callout
, exists: () => true, + groupIndex: 0, dropOntoWorkspace: () => {}, hasSuggestionForField: () => false, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx index 11adf1a128c1b..11710ffa18068 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx @@ -5,7 +5,7 @@ */ import './datapanel.scss'; -import React, { memo, useCallback } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiText, @@ -50,11 +50,12 @@ export interface FieldsAccordionProps { exists: (field: IndexPatternField) => boolean; showExistenceFetchError?: boolean; hideDetails?: boolean; + groupIndex: number; dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace']; hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField']; } -export const InnerFieldsAccordion = function InnerFieldsAccordion({ +export const FieldsAccordion = memo(function InnerFieldsAccordion({ initialIsOpen, onToggle, id, @@ -69,28 +70,72 @@ export const InnerFieldsAccordion = function InnerFieldsAccordion({ exists, hideDetails, showExistenceFetchError, + groupIndex, dropOntoWorkspace, hasSuggestionForField, }: FieldsAccordionProps) { const renderField = useCallback( - (field: IndexPatternField) => ( + (field: IndexPatternField, index) => ( ), - [fieldProps, exists, hideDetails, dropOntoWorkspace, hasSuggestionForField] + [fieldProps, exists, hideDetails, dropOntoWorkspace, hasSuggestionForField, groupIndex] ); - const titleClassname = classNames({ - // eslint-disable-next-line @typescript-eslint/naming-convention - lnsInnerIndexPatternDataPanel__titleTooltip: !!helpTooltip, - }); + const renderButton = useMemo(() => { + const titleClassname = classNames({ + // eslint-disable-next-line @typescript-eslint/naming-convention + lnsInnerIndexPatternDataPanel__titleTooltip: !!helpTooltip, + }); + return ( + + {label} + {!!helpTooltip && ( + + )} + + ); + }, [label, helpTooltip]); + + const extraAction = useMemo(() => { + return showExistenceFetchError ? ( + + ) : hasLoaded ? ( + + {fieldsCount} + + ) : ( + + ); + }, [showExistenceFetchError, hasLoaded, isFiltered, fieldsCount]); return ( - {label} - {!!helpTooltip && ( - - )} - - } - extraAction={ - showExistenceFetchError ? ( - - ) : hasLoaded ? ( - - {fieldsCount} - - ) : ( - - ) - } + buttonContent={renderButton} + extraAction={extraAction} > {hasLoaded && @@ -148,6 +157,4 @@ export const InnerFieldsAccordion = function InnerFieldsAccordion({ ))} ); -}; - -export const FieldsAccordion = memo(InnerFieldsAccordion); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index e51cd36156d1b..7f77a7ce199b6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -51,11 +51,11 @@ import { mergeLayer } from './state_helpers'; import { Datasource, StateSetter } from '../types'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; import { deleteColumn, isReferenced } from './operations'; -import { Dragging } from '../drag_drop/providers'; +import { DragDropIdentifier } from '../drag_drop/providers'; export { OperationType, IndexPatternColumn, deleteColumn } from './operations'; -export type DraggedField = Dragging & { +export type DraggedField = DragDropIdentifier & { field: IndexPatternField; indexPatternId: string; }; @@ -167,7 +167,7 @@ export function getIndexPatternDatasource({ }); }, - toExpression, + toExpression: (state, layerId) => toExpression(state, layerId, uiSettings), renderDataPanel( domElement: Element, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts index 4aea9e8ac67a9..67ddbe8c45ab7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts @@ -247,5 +247,10 @@ export function createMockedDragDropContext(): jest.Mocked { return { dragging: undefined, setDragging: jest.fn(), + activeDropTarget: undefined, + setActiveDropTarget: jest.fn(), + keyboardMode: false, + setKeyboardMode: jest.fn(), + setA11yMessage: jest.fn(), }; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx index abd033c0db4cf..22275533b9554 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx @@ -83,9 +83,11 @@ const indexPattern2: IndexPattern = { ]), }; +const uiSettingsMock = {} as IUiSettingsClient; + const defaultOptions = { storage: {} as IStorageWrapper, - uiSettings: {} as IUiSettingsClient, + uiSettings: uiSettingsMock, savedObjectsClient: {} as SavedObjectsClientContract, dateRange: { fromDate: 'now-1y', @@ -200,7 +202,8 @@ describe('date_histogram', () => { layer.columns.col1 as DateHistogramIndexPatternColumn, 'col1', indexPattern1, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( expect.objectContaining({ @@ -252,7 +255,8 @@ describe('date_histogram', () => { }, ]), }, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( expect.objectContaining({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx index 86767fbc8b469..3657013fa0bfa 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx @@ -16,9 +16,11 @@ import type { IndexPatternLayer } from '../../../types'; import { createMockedIndexPattern } from '../../../mocks'; import { FilterPopover } from './filter_popover'; +const uiSettingsMock = {} as IUiSettingsClient; + const defaultProps = { storage: {} as IStorageWrapper, - uiSettings: {} as IUiSettingsClient, + uiSettings: uiSettingsMock, savedObjectsClient: {} as SavedObjectsClientContract, dateRange: { fromDate: 'now-1d', toDate: 'now' }, data: dataPluginMock.createStartContract(), @@ -84,7 +86,8 @@ describe('filters', () => { layer.columns.col1 as FiltersIndexPatternColumn, 'col1', createMockedIndexPattern(), - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( expect.objectContaining({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index 1cdaff53c5458..0c0aa34bb40b3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -239,7 +239,8 @@ interface FieldlessOperationDefinition { column: C, columnId: string, indexPattern: IndexPattern, - layer: IndexPatternLayer + layer: IndexPatternLayer, + uiSettings: IUiSettingsClient ) => ExpressionAstFunction; } @@ -283,7 +284,8 @@ interface FieldBasedOperationDefinition { column: C, columnId: string, indexPattern: IndexPattern, - layer: IndexPatternLayer + layer: IndexPatternLayer, + uiSettings: IUiSettingsClient ) => ExpressionAstFunction; /** * Optional function to return the suffix used for ES bucket paths and esaggs column id. diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx index 96b12a714e613..8d5ab50770111 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx @@ -15,9 +15,11 @@ import { LastValueIndexPatternColumn } from './last_value'; import { lastValueOperation } from './index'; import type { IndexPattern, IndexPatternLayer } from '../../types'; +const uiSettingsMock = {} as IUiSettingsClient; + const defaultProps = { storage: {} as IStorageWrapper, - uiSettings: {} as IUiSettingsClient, + uiSettings: uiSettingsMock, savedObjectsClient: {} as SavedObjectsClientContract, dateRange: { fromDate: 'now-1d', toDate: 'now' }, data: dataPluginMock.createStartContract(), @@ -70,7 +72,8 @@ describe('last_value', () => { { ...lastValueColumn, params: { ...lastValueColumn.params } }, 'col1', {} as IndexPattern, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( expect.objectContaining({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx index c22eec62ea1ab..a340e17121e90 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx @@ -17,9 +17,11 @@ import { EuiFieldNumber } from '@elastic/eui'; import { act } from 'react-dom/test-utils'; import { EuiFormRow } from '@elastic/eui'; +const uiSettingsMock = {} as IUiSettingsClient; + const defaultProps = { storage: {} as IStorageWrapper, - uiSettings: {} as IUiSettingsClient, + uiSettings: uiSettingsMock, savedObjectsClient: {} as SavedObjectsClientContract, dateRange: { fromDate: 'now-1d', toDate: 'now' }, data: dataPluginMock.createStartContract(), @@ -72,7 +74,8 @@ describe('percentile', () => { percentileColumn, 'col1', {} as IndexPattern, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( expect.objectContaining({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx index ad5c146ff6624..d9698252177b0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -190,15 +190,6 @@ export const RangeEditor = ({ }) => { const [isAdvancedEditor, toggleAdvancedEditor] = useState(params.type === MODES.Range); - // if the maxBars in the params is set to auto refresh it with the default value only on bootstrap - useEffect(() => { - if (!isAdvancedEditor) { - if (params.maxBars !== maxBars) { - setParam('maxBars', maxBars); - } - } - }, [maxBars, params.maxBars, setParam, isAdvancedEditor]); - if (isAdvancedEditor) { return ( & React.MouseEvent; +// need this for MAX_HISTOGRAM value +const uiSettingsMock = ({ + get: jest.fn().mockReturnValue(100), +} as unknown) as IUiSettingsClient; + const sourceField = 'MyField'; const defaultOptions = { storage: {} as IStorageWrapper, - // need this for MAX_HISTOGRAM value - uiSettings: ({ - get: () => 100, - } as unknown) as IUiSettingsClient, + uiSettings: uiSettingsMock, savedObjectsClient: {} as SavedObjectsClientContract, dateRange: { fromDate: 'now-1y', @@ -143,7 +145,8 @@ describe('ranges', () => { layer.columns.col1 as RangeIndexPatternColumn, 'col1', {} as IndexPattern, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toMatchInlineSnapshot(` Object { @@ -166,6 +169,9 @@ describe('ranges', () => { "interval": Array [ "auto", ], + "maxBars": Array [ + 49.5, + ], "min_doc_count": Array [ false, ], @@ -186,7 +192,8 @@ describe('ranges', () => { layer.columns.col1 as RangeIndexPatternColumn, 'col1', {} as IndexPattern, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( @@ -206,7 +213,8 @@ describe('ranges', () => { layer.columns.col1 as RangeIndexPatternColumn, 'col1', {} as IndexPattern, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( @@ -226,7 +234,8 @@ describe('ranges', () => { layer.columns.col1 as RangeIndexPatternColumn, 'col1', {} as IndexPattern, - layer + layer, + uiSettingsMock ); expect((esAggsFn as { arguments: unknown }).arguments).toEqual( @@ -275,7 +284,7 @@ describe('ranges', () => { it('should start update the state with the default maxBars value', () => { const updateLayerSpy = jest.fn(); - mount( + const instance = mount( { /> ); - expect(updateLayerSpy).toHaveBeenCalledWith({ - ...layer, - columns: { - ...layer.columns, - col1: { - ...layer.columns.col1, - params: { - ...layer.columns.col1.params, - maxBars: GRANULARITY_DEFAULT_VALUE, - }, - }, - }, - }); + expect(instance.find(EuiRange).prop('value')).toEqual(String(GRANULARITY_DEFAULT_VALUE)); }); it('should update state when changing Max bars number', () => { @@ -313,8 +310,6 @@ describe('ranges', () => { /> ); - // There's a useEffect in the component that updates the value on bootstrap - // because there's a debouncer, wait a bit before calling onChange act(() => { jest.advanceTimersByTime(TYPING_DEBOUNCE_TIME * 4); @@ -358,8 +353,6 @@ describe('ranges', () => { /> ); - // There's a useEffect in the component that updates the value on bootstrap - // because there's a debouncer, wait a bit before calling onChange act(() => { jest.advanceTimersByTime(TYPING_DEBOUNCE_TIME * 4); // minus button @@ -368,6 +361,7 @@ describe('ranges', () => { .find('button') .prop('onClick')!({} as ReactMouseEvent); jest.advanceTimersByTime(TYPING_DEBOUNCE_TIME * 4); + instance.update(); }); expect(updateLayerSpy).toHaveBeenCalledWith({ @@ -391,6 +385,7 @@ describe('ranges', () => { .find('button') .prop('onClick')!({} as ReactMouseEvent); jest.advanceTimersByTime(TYPING_DEBOUNCE_TIME * 4); + instance.update(); }); expect(updateLayerSpy).toHaveBeenCalledWith({ @@ -788,7 +783,7 @@ describe('ranges', () => { instance.find(EuiLink).first().prop('onClick')!({} as ReactMouseEvent); }); - expect(updateLayerSpy.mock.calls[1][0].columns.col1.params.format).toEqual({ + expect(updateLayerSpy.mock.calls[0][0].columns.col1.params.format).toEqual({ id: 'custom', params: { decimals: 3 }, }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx index aa5cc8255a584..d8622a5aedf7d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx @@ -132,7 +132,7 @@ export const rangeOperation: OperationDefinition { + toEsAggsFn: (column, columnId, indexPattern, layer, uiSettings) => { const { sourceField, params } = column; if (params.type === MODES.Range) { return buildExpressionFunction('aggRange', { @@ -158,13 +158,15 @@ export const rangeOperation: OperationDefinition('aggHistogram', { id: columnId, enabled: true, schema: 'segment', field: sourceField, - // fallback to 0 in case of empty string - maxBars: params.maxBars === AUTO_BARS ? undefined : params.maxBars, + maxBars: params.maxBars === AUTO_BARS ? maxBarsDefaultValue : params.maxBars, interval: 'auto', has_extended_bounds: false, min_doc_count: false, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx index d60992bda2e2a..3e25e127b37f4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx @@ -17,9 +17,11 @@ import type { TermsIndexPatternColumn } from '.'; import { termsOperation } from '../index'; import { IndexPattern, IndexPatternLayer } from '../../../types'; +const uiSettingsMock = {} as IUiSettingsClient; + const defaultProps = { storage: {} as IStorageWrapper, - uiSettings: {} as IUiSettingsClient, + uiSettings: uiSettingsMock, savedObjectsClient: {} as SavedObjectsClientContract, dateRange: { fromDate: 'now-1d', toDate: 'now' }, data: dataPluginMock.createStartContract(), @@ -66,7 +68,8 @@ describe('terms', () => { { ...termsColumn, params: { ...termsColumn.params, otherBucket: true } }, 'col1', {} as IndexPattern, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( expect.objectContaining({ @@ -89,7 +92,8 @@ describe('terms', () => { }, 'col1', {} as IndexPattern, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( expect.objectContaining({ @@ -129,7 +133,8 @@ describe('terms', () => { }, }, }, - } + }, + uiSettingsMock ); expect(esAggsFn).toEqual( expect.objectContaining({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index 38f51f24aae7d..c9ee77a9f5e15 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import type { IUiSettingsClient } from 'kibana/public'; import { EsaggsExpressionFunctionDefinition, IndexPatternLoadExpressionFunctionDefinition, @@ -24,7 +25,8 @@ import { getEsAggsSuffix } from './operations/definitions/helpers'; function getExpressionForLayer( layer: IndexPatternLayer, - indexPattern: IndexPattern + indexPattern: IndexPattern, + uiSettings: IUiSettingsClient ): ExpressionAstExpression | null { const { columns, columnOrder } = layer; if (columnOrder.length === 0) { @@ -44,7 +46,7 @@ function getExpressionForLayer( aggs.push( buildExpression({ type: 'expression', - chain: [def.toEsAggsFn(col, colId, indexPattern, layer)], + chain: [def.toEsAggsFn(col, colId, indexPattern, layer, uiSettings)], }) ); } @@ -184,11 +186,16 @@ function getExpressionForLayer( return null; } -export function toExpression(state: IndexPatternPrivateState, layerId: string) { +export function toExpression( + state: IndexPatternPrivateState, + layerId: string, + uiSettings: IUiSettingsClient +) { if (state.layers[layerId]) { return getExpressionForLayer( state.layers[layerId], - state.indexPatterns[state.layers[layerId].indexPatternId] + state.indexPatterns[state.layers[layerId].indexPatternId], + uiSettings ); } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 907ef3a700ce6..8f202faeb9ee8 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -16,7 +16,7 @@ import { Datatable, SerializedFieldFormat, } from '../../../../src/plugins/expressions/public'; -import { DragContextState, Dragging } from './drag_drop'; +import { DragContextState, DragDropIdentifier } from './drag_drop'; import { Document } from './persistence'; import { DateRange } from '../common'; import { Query, Filter, SavedQuery, IFieldFormat } from '../../../../src/plugins/data/public'; @@ -226,8 +226,8 @@ export interface DatasourceDataPanelProps { query: Query; dateRange: DateRange; filters: Filter[]; - dropOntoWorkspace: (field: Dragging) => void; - hasSuggestionForField: (field: Dragging) => boolean; + dropOntoWorkspace: (field: DragDropIdentifier) => void; + hasSuggestionForField: (field: DragDropIdentifier) => boolean; } interface SharedDimensionProps { @@ -301,6 +301,8 @@ export type DatasourceDimensionDropProps = SharedDimensionProps & { export type DatasourceDimensionDropHandlerProps = DatasourceDimensionDropProps & { droppedItem: unknown; + groupId: string; + isNew?: boolean; }; export type DataType = 'document' | 'string' | 'number' | 'date' | 'boolean' | 'ip'; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index b8bca09bb353c..91fa2f5921d2f 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -5,7 +5,7 @@ */ import './xy_config_panel.scss'; -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useState, memo } from 'react'; import { i18n } from '@kbn/i18n'; import { Position } from '@elastic/charts'; import { debounce } from 'lodash'; @@ -179,8 +179,7 @@ function getValueLabelDisableReason({ defaultMessage: 'This setting cannot be changed on stacked or percentage bar charts', }); } - -export function XyToolbar(props: VisualizationToolbarProps) { +export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProps) { const { state, setState, frame } = props; const hasNonBarSeries = state?.layers.some(({ seriesType }) => @@ -485,7 +484,8 @@ export function XyToolbar(props: VisualizationToolbarProps) { ); -} +}); + const idPrefix = htmlIdGenerator()(); export function DimensionEditor( @@ -653,7 +653,7 @@ const ColorPicker = ({ } }; - const updateColorInState: EuiColorPickerProps['onChange'] = React.useMemo( + const updateColorInState: EuiColorPickerProps['onChange'] = useMemo( () => debounce((text, output) => { const newYConfigs = [...(layer.yConfig || [])]; diff --git a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts index b00281588734d..c9391e1aac749 100644 --- a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts @@ -18,6 +18,7 @@ export type MapFilters = { filters: Filter[]; query?: MapQuery; refreshTimerLastTriggeredAt?: string; + searchSessionId?: string; timeFilters: TimeRange; zoom: number; }; diff --git a/x-pack/plugins/maps/public/actions/map_actions.test.js b/x-pack/plugins/maps/public/actions/map_actions.test.js index 1d1f8a511c4fa..c091aba14687a 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.test.js +++ b/x-pack/plugins/maps/public/actions/map_actions.test.js @@ -260,6 +260,7 @@ describe('map_actions', () => { $state: { store: 'appState' }, }, ]; + const searchSessionId = '1234'; beforeEach(() => { //Mocks the "previous" state @@ -272,6 +273,9 @@ describe('map_actions', () => { require('../selectors/map_selectors').getFilters = () => { return filters; }; + require('../selectors/map_selectors').getSearchSessionId = () => { + return searchSessionId; + }; require('../selectors/map_selectors').getMapSettings = () => { return { autoFitToDataBounds: false, @@ -288,12 +292,14 @@ describe('map_actions', () => { const setQueryAction = await setQuery({ query: newQuery, filters, + searchSessionId, }); await setQueryAction(dispatchMock, getStoreMock); expect(dispatchMock.mock.calls).toEqual([ [ { + searchSessionId, timeFilters, query: newQuery, filters, @@ -304,11 +310,25 @@ describe('map_actions', () => { ]); }); + it('should dispatch query action when searchSessionId changes', async () => { + const setQueryAction = await setQuery({ + timeFilters, + query, + filters, + searchSessionId: '5678', + }); + await setQueryAction(dispatchMock, getStoreMock); + + // dispatchMock calls: dispatch(SET_QUERY) and dispatch(syncDataForAllLayers()) + expect(dispatchMock.mock.calls.length).toEqual(2); + }); + it('should not dispatch query action when nothing changes', async () => { const setQueryAction = await setQuery({ timeFilters, query, filters, + searchSessionId, }); await setQueryAction(dispatchMock, getStoreMock); diff --git a/x-pack/plugins/maps/public/actions/map_actions.ts b/x-pack/plugins/maps/public/actions/map_actions.ts index 64c35bd207439..afb3df5be73de 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.ts @@ -19,6 +19,7 @@ import { getQuery, getTimeFilters, getLayerList, + getSearchSessionId, } from '../selectors/map_selectors'; import { CLEAR_GOTO, @@ -225,11 +226,13 @@ export function setQuery({ timeFilters, filters = [], forceRefresh = false, + searchSessionId, }: { filters?: Filter[]; query?: Query; timeFilters?: TimeRange; forceRefresh?: boolean; + searchSessionId?: string; }) { return async ( dispatch: ThunkDispatch, @@ -249,12 +252,14 @@ export function setQuery({ queryLastTriggeredAt: forceRefresh ? generateQueryTimestamp() : prevTriggeredAt, }, filters: filters ? filters : getFilters(getState()), + searchSessionId, }; const prevQueryContext = { timeFilters: getTimeFilters(getState()), query: getQuery(getState()), filters: getFilters(getState()), + searchSessionId: getSearchSessionId(getState()), }; if (_.isEqual(nextQueryContext, prevQueryContext)) { diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts index 5b33738a91a28..88150da84f23f 100644 --- a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts @@ -316,7 +316,10 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { const abortController = new AbortController(); syncContext.registerCancelCallback(requestToken, () => abortController.abort()); const searchSource = await this._documentSource.makeSearchSource(searchFilters, 0); - const resp = await searchSource.fetch({ abortSignal: abortController.signal }); + const resp = await searchSource.fetch({ + abortSignal: abortController.signal, + sessionId: syncContext.dataFilters.searchSessionId, + }); const maxResultWindow = await this._documentSource.getMaxResultWindow(); isSyncClustered = resp.hits.total > maxResultWindow; const countData = { isSyncClustered } as CountData; diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx index 0cb24be445c6e..2304bb277da49 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx @@ -616,6 +616,7 @@ export class VectorLayer extends AbstractLayer { sourceQuery, isTimeAware: this.getCurrentStyle().isTimeAware() && (await source.isTimeAware()), timeFilters: dataFilters.timeFilters, + searchSessionId: dataFilters.searchSessionId, } as VectorStyleRequestMeta; const prevDataRequest = this.getDataRequest(dataRequestId); const canSkipFetch = canSkipStyleMetaUpdate({ prevDataRequest, nextMeta }); @@ -635,6 +636,7 @@ export class VectorLayer extends AbstractLayer { registerCancelCallback: registerCancelCallback.bind(null, requestToken), sourceQuery: nextMeta.sourceQuery, timeFilters: nextMeta.timeFilters, + searchSessionId: dataFilters.searchSessionId, }); stopLoading(dataRequestId, requestToken, styleMeta, nextMeta); } catch (error) { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx index 6ec51b8e118cb..24b7e0dec519c 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx @@ -195,6 +195,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle async _compositeAggRequest({ searchSource, + searchSessionId, indexPattern, precision, layerName, @@ -204,6 +205,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle bufferedExtent, }: { searchSource: ISearchSource; + searchSessionId?: string; indexPattern: IndexPattern; precision: number; layerName: string; @@ -280,6 +282,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle values: { requestId }, } ), + searchSessionId, }); features.push(...convertCompositeRespToGeoJson(esResponse, this._descriptor.requestType)); @@ -325,6 +328,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle // see https://github.com/elastic/kibana/pull/57875#issuecomment-590515482 for explanation on using separate code paths async _nonCompositeAggRequest({ searchSource, + searchSessionId, indexPattern, precision, layerName, @@ -332,6 +336,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle bufferedExtent, }: { searchSource: ISearchSource; + searchSessionId?: string; indexPattern: IndexPattern; precision: number; layerName: string; @@ -348,6 +353,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle requestDescription: i18n.translate('xpack.maps.source.esGrid.inspectorDescription', { defaultMessage: 'Elasticsearch geo grid aggregation request', }), + searchSessionId, }); return convertRegularRespToGeoJson(esResponse, this._descriptor.requestType); @@ -373,6 +379,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle bucketsPerGrid === 1 ? await this._nonCompositeAggRequest({ searchSource, + searchSessionId: searchFilters.searchSessionId, indexPattern, precision: searchFilters.geogridPrecision || 0, layerName, @@ -381,6 +388,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle }) : await this._compositeAggRequest({ searchSource, + searchSessionId: searchFilters.searchSessionId, indexPattern, precision: searchFilters.geogridPrecision || 0, layerName, diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx index 9c851dcedb3fa..916a8a291e6b4 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx @@ -213,6 +213,7 @@ export class ESGeoLineSource extends AbstractESAggSource { requestDescription: i18n.translate('xpack.maps.source.esGeoLine.entityRequestDescription', { defaultMessage: 'Elasticsearch terms request to fetch entities within map buffer.', }), + searchSessionId: searchFilters.searchSessionId, }); const entityBuckets: Array<{ key: string; doc_count: number }> = _.get( entityResp, @@ -282,6 +283,7 @@ export class ESGeoLineSource extends AbstractESAggSource { defaultMessage: 'Elasticsearch geo_line request to fetch tracks for entities. Tracks are not filtered by map buffer.', }), + searchSessionId: searchFilters.searchSessionId, }); const { featureCollection, numTrimmedTracks } = convertToGeoJson( tracksResp, diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js index 504212ea1ea84..98d3ba6267c6d 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js @@ -148,6 +148,7 @@ export class ESPewPewSource extends AbstractESAggSource { requestDescription: i18n.translate('xpack.maps.source.pewPew.inspectorDescription', { defaultMessage: 'Source-destination connections request', }), + searchSessionId: searchFilters.searchSessionId, }); const { featureCollection } = convertToLines(esResponse); diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx index 5a923f0ce4292..b70a433f2c729 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx @@ -325,6 +325,7 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye searchSource, registerCancelCallback, requestDescription: 'Elasticsearch document top hits request', + searchSessionId: searchFilters.searchSessionId, }); const allHits: any[] = []; @@ -391,6 +392,7 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye searchSource, registerCancelCallback, requestDescription: 'Elasticsearch document request', + searchSessionId: searchFilters.searchSessionId, }); return { diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts index 967131e900fc6..64a5cd575a19d 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts @@ -57,6 +57,7 @@ export interface IESSource extends IVectorSource { registerCancelCallback, sourceQuery, timeFilters, + searchSessionId, }: { layerName: string; style: IVectorStyle; @@ -64,6 +65,7 @@ export interface IESSource extends IVectorSource { registerCancelCallback: (callback: () => void) => void; sourceQuery?: MapQuery; timeFilters: TimeRange; + searchSessionId?: string; }): Promise; } @@ -151,17 +153,19 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource } async _runEsQuery({ + registerCancelCallback, + requestDescription, requestId, requestName, - requestDescription, + searchSessionId, searchSource, - registerCancelCallback, }: { + registerCancelCallback: (callback: () => void) => void; + requestDescription: string; requestId: string; requestName: string; - requestDescription: string; + searchSessionId?: string; searchSource: ISearchSource; - registerCancelCallback: (callback: () => void) => void; }): Promise { const abortController = new AbortController(); registerCancelCallback(() => abortController.abort()); @@ -172,6 +176,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource inspectorRequest = inspectorAdapters.requests.start(requestName, { id: requestId, description: requestDescription, + searchSessionId, }); } @@ -186,7 +191,10 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource } }); } - resp = await searchSource.fetch({ abortSignal: abortController.signal }); + resp = await searchSource.fetch({ + abortSignal: abortController.signal, + sessionId: searchSessionId, + }); if (inspectorRequest) { const responseStats = search.getResponseInspectorStats(resp, searchSource); inspectorRequest.stats(responseStats).ok({ json: resp }); @@ -404,6 +412,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource registerCancelCallback, sourceQuery, timeFilters, + searchSessionId, }: { layerName: string; style: IVectorStyle; @@ -411,6 +420,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource registerCancelCallback: (callback: () => void) => void; sourceQuery?: MapQuery; timeFilters: TimeRange; + searchSessionId?: string; }): Promise { const promises = dynamicStyleProps.map((dynamicStyleProp) => { return dynamicStyleProp.getFieldMetaRequest(); @@ -456,6 +466,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource 'Elasticsearch request retrieving field metadata used for calculating symbolization bands.', } ), + searchSessionId, }); return resp.aggregations; diff --git a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts index 12f1ef4829a4a..235e8e3a651ee 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts @@ -147,6 +147,7 @@ export class ESTermSource extends AbstractESAggSource { rightSource: `${this._descriptor.indexPatternTitle}:${this._termField.getName()}`, }, }), + searchSessionId: searchFilters.searchSessionId, }); const countPropertyName = this.getAggKey(AGG_TYPE.COUNT); diff --git a/x-pack/plugins/maps/public/classes/util/can_skip_fetch.ts b/x-pack/plugins/maps/public/classes/util/can_skip_fetch.ts index a7919ad058e4b..d7a5eea151602 100644 --- a/x-pack/plugins/maps/public/classes/util/can_skip_fetch.ts +++ b/x-pack/plugins/maps/public/classes/util/can_skip_fetch.ts @@ -113,6 +113,7 @@ export async function canSkipSourceUpdate({ if (isQueryAware) { updateDueToApplyGlobalQuery = prevMeta.applyGlobalQuery !== nextMeta.applyGlobalQuery; updateDueToSourceQuery = !_.isEqual(prevMeta.sourceQuery, nextMeta.sourceQuery); + if (nextMeta.applyGlobalQuery) { updateDueToQuery = !_.isEqual(prevMeta.query, nextMeta.query); updateDueToFilters = !_.isEqual(prevMeta.filters, nextMeta.filters); @@ -123,6 +124,11 @@ export async function canSkipSourceUpdate({ } } + let updateDueToSearchSessionId = false; + if (timeAware || isQueryAware) { + updateDueToSearchSessionId = prevMeta.searchSessionId !== nextMeta.searchSessionId; + } + let updateDueToPrecisionChange = false; if (isGeoGridPrecisionAware) { updateDueToPrecisionChange = !_.isEqual(prevMeta.geogridPrecision, nextMeta.geogridPrecision); @@ -146,7 +152,8 @@ export async function canSkipSourceUpdate({ !updateDueToSourceQuery && !updateDueToApplyGlobalQuery && !updateDueToPrecisionChange && - !updateDueToSourceMetaChange + !updateDueToSourceMetaChange && + !updateDueToSearchSessionId ); } @@ -174,8 +181,14 @@ export function canSkipStyleMetaUpdate({ ? !_.isEqual(prevMeta.timeFilters, nextMeta.timeFilters) : false; + const updateDueToSearchSessionId = prevMeta.searchSessionId !== nextMeta.searchSessionId; + return ( - !updateDueToFields && !updateDueToSourceQuery && !updateDueToIsTimeAware && !updateDueToTime + !updateDueToFields && + !updateDueToSourceQuery && + !updateDueToIsTimeAware && + !updateDueToTime && + !updateDueToSearchSessionId ); } diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index bcdc23bddd2eb..623e548aa85fa 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -82,6 +82,7 @@ export class MapEmbeddable private _prevQuery?: Query; private _prevRefreshConfig?: RefreshInterval; private _prevFilters?: Filter[]; + private _prevSearchSessionId?: string; private _domNode?: HTMLElement; private _unsubscribeFromStore?: Unsubscribe; private _isInitialized = false; @@ -99,9 +100,7 @@ export class MapEmbeddable this._savedMap = new SavedMap({ mapEmbeddableInput: initialInput }); this._initializeSaveMap(); - this._subscription = this.getUpdated$().subscribe(() => - this.onContainerStateChanged(this.input) - ); + this._subscription = this.getUpdated$().subscribe(() => this.onUpdate()); } private async _initializeSaveMap() { @@ -135,6 +134,7 @@ export class MapEmbeddable timeRange: this.input.timeRange, filters: this.input.filters, forceRefresh: false, + searchSessionId: this.input.searchSessionId, }); if (this.input.refreshConfig) { this._dispatchSetRefreshConfig(this.input.refreshConfig); @@ -201,25 +201,24 @@ export class MapEmbeddable return getInspectorAdapters(this._savedMap.getStore().getState()); } - onContainerStateChanged(containerState: MapEmbeddableInput) { + onUpdate() { if ( - !_.isEqual(containerState.timeRange, this._prevTimeRange) || - !_.isEqual(containerState.query, this._prevQuery) || - !esFilters.onlyDisabledFiltersChanged(containerState.filters, this._prevFilters) + !_.isEqual(this.input.timeRange, this._prevTimeRange) || + !_.isEqual(this.input.query, this._prevQuery) || + !esFilters.onlyDisabledFiltersChanged(this.input.filters, this._prevFilters) || + this.input.searchSessionId !== this._prevSearchSessionId ) { this._dispatchSetQuery({ - query: containerState.query, - timeRange: containerState.timeRange, - filters: containerState.filters, + query: this.input.query, + timeRange: this.input.timeRange, + filters: this.input.filters, forceRefresh: false, + searchSessionId: this.input.searchSessionId, }); } - if ( - containerState.refreshConfig && - !_.isEqual(containerState.refreshConfig, this._prevRefreshConfig) - ) { - this._dispatchSetRefreshConfig(containerState.refreshConfig); + if (this.input.refreshConfig && !_.isEqual(this.input.refreshConfig, this._prevRefreshConfig)) { + this._dispatchSetRefreshConfig(this.input.refreshConfig); } } @@ -228,21 +227,25 @@ export class MapEmbeddable timeRange, filters = [], forceRefresh, + searchSessionId, }: { query?: Query; timeRange?: TimeRange; filters?: Filter[]; forceRefresh: boolean; + searchSessionId?: string; }) { this._prevTimeRange = timeRange; this._prevQuery = query; this._prevFilters = filters; + this._prevSearchSessionId = searchSessionId; this._savedMap.getStore().dispatch( setQuery({ filters: filters.filter((filter) => !filter.meta.disabled), query, timeFilters: timeRange, forceRefresh, + searchSessionId, }) ); } @@ -380,10 +383,11 @@ export class MapEmbeddable reload() { this._dispatchSetQuery({ - query: this._prevQuery, - timeRange: this._prevTimeRange, - filters: this._prevFilters ?? [], + query: this.input.query, + timeRange: this.input.timeRange, + filters: this.input.filters, forceRefresh: true, + searchSessionId: this.input.searchSessionId, }); } diff --git a/x-pack/plugins/maps/public/reducers/map.d.ts b/x-pack/plugins/maps/public/reducers/map.d.ts index 273d1de6fddfe..52df65d6d2ecc 100644 --- a/x-pack/plugins/maps/public/reducers/map.d.ts +++ b/x-pack/plugins/maps/public/reducers/map.d.ts @@ -34,6 +34,7 @@ export type MapContext = { refreshConfig?: MapRefreshConfig; refreshTimerLastTriggeredAt?: string; drawState?: DrawState; + searchSessionId?: string; }; export type MapSettings = { diff --git a/x-pack/plugins/maps/public/reducers/map.js b/x-pack/plugins/maps/public/reducers/map.js index 1395f2c5ce2fe..f068abee48b93 100644 --- a/x-pack/plugins/maps/public/reducers/map.js +++ b/x-pack/plugins/maps/public/reducers/map.js @@ -240,7 +240,7 @@ export function map(state = DEFAULT_MAP_STATE, action) { }; return { ...state, mapState: { ...state.mapState, ...newMapState } }; case SET_QUERY: - const { query, timeFilters, filters } = action; + const { query, timeFilters, filters, searchSessionId } = action; return { ...state, mapState: { @@ -248,6 +248,7 @@ export function map(state = DEFAULT_MAP_STATE, action) { query, timeFilters, filters, + searchSessionId, }, }; case SET_REFRESH_CONFIG: diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.ts b/x-pack/plugins/maps/public/selectors/map_selectors.ts index 8876b9536ce92..21ce5993b7c89 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.ts @@ -169,6 +169,9 @@ export const getQuery = ({ map }: MapStoreState): MapQuery | undefined => map.ma export const getFilters = ({ map }: MapStoreState): Filter[] => map.mapState.filters; +export const getSearchSessionId = ({ map }: MapStoreState): string | undefined => + map.mapState.searchSessionId; + export const isUsingSearch = (state: MapStoreState): boolean => { const filters = getFilters(state).filter((filter) => !filter.meta.disabled); const queryString = _.get(getQuery(state), 'query', ''); @@ -220,7 +223,17 @@ export const getDataFilters = createSelector( getRefreshTimerLastTriggeredAt, getQuery, getFilters, - (mapExtent, mapBuffer, mapZoom, timeFilters, refreshTimerLastTriggeredAt, query, filters) => { + getSearchSessionId, + ( + mapExtent, + mapBuffer, + mapZoom, + timeFilters, + refreshTimerLastTriggeredAt, + query, + filters, + searchSessionId + ) => { return { extent: mapExtent, buffer: mapBuffer, @@ -229,6 +242,7 @@ export const getDataFilters = createSelector( refreshTimerLastTriggeredAt, query, filters, + searchSessionId, }; } ); diff --git a/x-pack/plugins/ml/common/util/job_utils.ts b/x-pack/plugins/ml/common/util/job_utils.ts index 4f4d9851c4957..d20ad4a368948 100644 --- a/x-pack/plugins/ml/common/util/job_utils.ts +++ b/x-pack/plugins/ml/common/util/job_utils.ts @@ -78,6 +78,18 @@ export function isTimeSeriesViewDetector(job: CombinedJob, detectorIndex: number ); } +// Returns a flag to indicate whether the specified job is suitable for embedded map viewing. +export function isMappableJob(job: CombinedJob, detectorIndex: number): boolean { + let isMappable = false; + const { detectors } = job.analysis_config; + if (detectorIndex >= 0 && detectorIndex < detectors.length) { + const dtr = detectors[detectorIndex]; + const functionName = dtr.function; + isMappable = functionName === ML_JOB_AGGREGATION.LAT_LONG; + } + return isMappable; +} + // Returns a flag to indicate whether the source data can be plotted in a time // series chart for the specified detector. export function isSourceDataChartableForDetector(job: CombinedJob, detectorIndex: number): boolean { diff --git a/x-pack/plugins/ml/public/application/components/ml_embedded_map/ml_embedded_map.tsx b/x-pack/plugins/ml/public/application/components/ml_embedded_map/ml_embedded_map.tsx index 917714899df7a..90632f41250a7 100644 --- a/x-pack/plugins/ml/public/application/components/ml_embedded_map/ml_embedded_map.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_embedded_map/ml_embedded_map.tsx @@ -8,6 +8,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { htmlIdGenerator } from '@elastic/eui'; import { LayerDescriptor } from '../../../../../maps/common/descriptor_types'; +import { INITIAL_LOCATION } from '../../../../../maps/common/constants'; import { MapEmbeddable, MapEmbeddableInput, @@ -82,13 +83,6 @@ export function MlEmbeddedMapComponent({ viewMode: ViewMode.VIEW, isLayerTOCOpen: false, hideFilterActions: true, - // Zoom Lat/Lon values are set to make sure map is in center in the panel - // It will also omit Greenland/Antarctica etc. NOTE: Can be removed when initialLocation is set - mapCenter: { - lon: 11, - lat: 20, - zoom: 1, - }, // can use mapSettings to center map on anomalies mapSettings: { disableInteractive: false, diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts index 4f564dde8cb43..903fe5b6ed985 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts @@ -4,5 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { useScatterplotFieldOptions } from './use_scatterplot_field_options'; export { LEGEND_TYPES } from './scatterplot_matrix_vega_lite_spec'; export { ScatterplotMatrix } from './scatterplot_matrix'; +export type { ScatterplotMatrixViewProps as ScatterplotMatrixProps } from './scatterplot_matrix_view'; diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx index b1ee9afb17788..a90fe924b91ac 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx @@ -4,316 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useEffect, useState, FC } from 'react'; +import React, { FC, Suspense } from 'react'; -// There is still an issue with Vega Lite's typings with the strict mode Kibana is using. -// @ts-ignore -import { compile } from 'vega-lite/build-es5/vega-lite'; -import { parse, View, Warn } from 'vega'; -import { Handler } from 'vega-tooltip'; +import type { ScatterplotMatrixViewProps } from './scatterplot_matrix_view'; +import { ScatterplotMatrixLoading } from './scatterplot_matrix_loading'; -import { - htmlIdGenerator, - EuiComboBox, - EuiComboBoxOptionOption, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiLoadingSpinner, - EuiSelect, - EuiSpacer, - EuiSwitch, - EuiText, -} from '@elastic/eui'; +const ScatterplotMatrixLazy = React.lazy(() => import('./scatterplot_matrix_view')); -import { i18n } from '@kbn/i18n'; - -import type { SearchResponse7 } from '../../../../common/types/es_client'; - -import { useMlApiContext } from '../../contexts/kibana'; - -import { getProcessedFields } from '../data_grid'; -import { useCurrentEuiTheme } from '../color_range_legend'; - -import { - getScatterplotMatrixVegaLiteSpec, - LegendType, - OUTLIER_SCORE_FIELD, -} from './scatterplot_matrix_vega_lite_spec'; - -import './scatterplot_matrix.scss'; - -const SCATTERPLOT_MATRIX_DEFAULT_FIELDS = 4; -const SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE = 1000; -const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE = 1; -const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE = 10000; - -const TOGGLE_ON = i18n.translate('xpack.ml.splom.toggleOn', { - defaultMessage: 'On', -}); -const TOGGLE_OFF = i18n.translate('xpack.ml.splom.toggleOff', { - defaultMessage: 'Off', -}); - -const sampleSizeOptions = [100, 1000, 10000].map((d) => ({ value: d, text: '' + d })); - -interface ScatterplotMatrixProps { - fields: string[]; - index: string; - resultsField?: string; - color?: string; - legendType?: LegendType; -} - -export const ScatterplotMatrix: FC = ({ - fields: allFields, - index, - resultsField, - color, - legendType, -}) => { - const { esSearch } = useMlApiContext(); - - // dynamicSize is optionally used for outlier charts where the scatterplot marks - // are sized according to outlier_score - const [dynamicSize, setDynamicSize] = useState(false); - - // used to give the use the option to customize the fields used for the matrix axes - const [fields, setFields] = useState([]); - - useEffect(() => { - const defaultFields = - allFields.length > SCATTERPLOT_MATRIX_DEFAULT_FIELDS - ? allFields.slice(0, SCATTERPLOT_MATRIX_DEFAULT_FIELDS) - : allFields; - setFields(defaultFields); - }, [allFields]); - - // the amount of documents to be fetched - const [fetchSize, setFetchSize] = useState(SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE); - // flag to add a random score to the ES query to fetch documents - const [randomizeQuery, setRandomizeQuery] = useState(false); - - const [isLoading, setIsLoading] = useState(false); - - // contains the fetched documents and columns to be passed on to the Vega spec. - const [splom, setSplom] = useState<{ items: any[]; columns: string[] } | undefined>(); - - // formats the array of field names for EuiComboBox - const fieldOptions = useMemo( - () => - allFields.map((d) => ({ - label: d, - })), - [allFields] - ); - - const fieldsOnChange = (newFields: EuiComboBoxOptionOption[]) => { - setFields(newFields.map((d) => d.label)); - }; - - const fetchSizeOnChange = (e: React.ChangeEvent) => { - setFetchSize( - Math.min( - Math.max(parseInt(e.target.value, 10), SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE), - SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE - ) - ); - }; - - const randomizeQueryOnChange = () => { - setRandomizeQuery(!randomizeQuery); - }; - - const dynamicSizeOnChange = () => { - setDynamicSize(!dynamicSize); - }; - - const { euiTheme } = useCurrentEuiTheme(); - - useEffect(() => { - async function fetchSplom(options: { didCancel: boolean }) { - setIsLoading(true); - try { - const queryFields = [ - ...fields, - ...(color !== undefined ? [color] : []), - ...(legendType !== undefined ? [] : [`${resultsField}.${OUTLIER_SCORE_FIELD}`]), - ]; - - const query = randomizeQuery - ? { - function_score: { - random_score: { seed: 10, field: '_seq_no' }, - }, - } - : { match_all: {} }; - - const resp: SearchResponse7 = await esSearch({ - index, - body: { - fields: queryFields, - _source: false, - query, - from: 0, - size: fetchSize, - }, - }); - - if (!options.didCancel) { - const items = resp.hits.hits.map((d) => - getProcessedFields(d.fields, (key: string) => - key.startsWith(`${resultsField}.feature_importance`) - ) - ); - - setSplom({ columns: fields, items }); - setIsLoading(false); - } - } catch (e) { - // TODO error handling - setIsLoading(false); - } - } - - const options = { didCancel: false }; - fetchSplom(options); - return () => { - options.didCancel = true; - }; - // stringify the fields array, otherwise the comparator will trigger on new but identical instances. - }, [fetchSize, JSON.stringify(fields), index, randomizeQuery, resultsField]); - - const htmlId = useMemo(() => htmlIdGenerator()(), []); - - useEffect(() => { - if (splom === undefined) { - return; - } - - const { items, columns } = splom; - - const values = - resultsField !== undefined - ? items - : items.map((d) => { - d[`${resultsField}.${OUTLIER_SCORE_FIELD}`] = 0; - return d; - }); - - const vegaSpec = getScatterplotMatrixVegaLiteSpec( - values, - columns, - euiTheme, - resultsField, - color, - legendType, - dynamicSize - ); - - const vgSpec = compile(vegaSpec).spec; - - const view = new View(parse(vgSpec)) - .logLevel(Warn) - .renderer('canvas') - .tooltip(new Handler().call) - .initialize(`#${htmlId}`); - - view.runAsync(); // evaluate and render the view - }, [resultsField, splom, color, legendType, dynamicSize]); - - return ( - <> - {splom === undefined ? ( - - - - - - ) : ( - <> - - - - ({ - label: d, - }))} - onChange={fieldsOnChange} - isClearable={true} - data-test-subj="mlScatterplotMatrixFieldsComboBox" - /> - - - - - - - - - - - - - {resultsField !== undefined && legendType === undefined && ( - - - - - - )} - - -
- - )} - - ); -}; +export const ScatterplotMatrix: FC = (props) => ( + }> + + +); diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_loading.tsx b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_loading.tsx new file mode 100644 index 0000000000000..ccd4153769e9c --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_loading.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui'; + +export const ScatterplotMatrixLoading = () => { + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts index dd467161ff489..eada64b7a03ca 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts @@ -163,6 +163,7 @@ describe('getScatterplotMatrixVegaLiteSpec()', () => { type: 'nominal', }); expect(vegaLiteSpec.spec.encoding.tooltip).toEqual([ + { field: 'the-color-field', type: 'nominal' }, { field: 'x', type: 'quantitative' }, { field: 'y', type: 'quantitative' }, ]); diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts index 9e0834dd8b922..c943e5d1b06e3 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts @@ -35,6 +35,8 @@ export const getColorSpec = ( color?: string, legendType?: LegendType ) => { + // For outlier detection result pages coloring is done based on a threshold. + // This returns a Vega spec using a conditional to return the color. if (outliers) { return { condition: { @@ -45,6 +47,8 @@ export const getColorSpec = ( }; } + // Based on the type of the color field, + // this returns either a continuous or categorical color spec. if (color !== undefined && legendType !== undefined) { return { field: color, @@ -80,6 +84,8 @@ export const getScatterplotMatrixVegaLiteSpec = ( }); } + const colorSpec = getColorSpec(euiTheme, outliers, color, legendType); + return { $schema: 'https://vega.github.io/schema/vega-lite/v4.17.0.json', background: 'transparent', @@ -115,10 +121,10 @@ export const getScatterplotMatrixVegaLiteSpec = ( : { type: 'circle', opacity: 0.75, size: 8 }), }, encoding: { - color: getColorSpec(euiTheme, outliers, color, legendType), + color: colorSpec, ...(dynamicSize ? { - stroke: getColorSpec(euiTheme, outliers, color, legendType), + stroke: colorSpec, opacity: { condition: { value: 1, @@ -163,6 +169,7 @@ export const getScatterplotMatrixVegaLiteSpec = ( scale: { zero: false }, }, tooltip: [ + ...(color !== undefined ? [{ type: colorSpec.type, field: color }] : []), ...columns.map((d) => ({ type: LEGEND_TYPES.QUANTITATIVE, field: d })), ...(outliers ? [{ type: LEGEND_TYPES.QUANTITATIVE, field: OUTLIER_SCORE_FIELD, format: '.3f' }] diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.scss b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.scss similarity index 100% rename from x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.scss rename to x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.scss diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.tsx b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.tsx new file mode 100644 index 0000000000000..0c065c1154a98 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.tsx @@ -0,0 +1,323 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo, useEffect, useState, FC } from 'react'; + +// There is still an issue with Vega Lite's typings with the strict mode Kibana is using. +// @ts-ignore +import { compile } from 'vega-lite/build-es5/vega-lite'; +import { parse, View, Warn } from 'vega'; +import { Handler } from 'vega-tooltip'; + +import { + htmlIdGenerator, + EuiComboBox, + EuiComboBoxOptionOption, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSelect, + EuiSwitch, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import type { SearchResponse7 } from '../../../../common/types/es_client'; +import type { ResultsSearchQuery } from '../../data_frame_analytics/common/analytics'; + +import { useMlApiContext } from '../../contexts/kibana'; + +import { getProcessedFields } from '../data_grid'; +import { useCurrentEuiTheme } from '../color_range_legend'; + +import { ScatterplotMatrixLoading } from './scatterplot_matrix_loading'; + +import { + getScatterplotMatrixVegaLiteSpec, + LegendType, + OUTLIER_SCORE_FIELD, +} from './scatterplot_matrix_vega_lite_spec'; + +import './scatterplot_matrix_view.scss'; + +const SCATTERPLOT_MATRIX_DEFAULT_FIELDS = 4; +const SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE = 1000; +const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE = 1; +const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE = 10000; + +const TOGGLE_ON = i18n.translate('xpack.ml.splom.toggleOn', { + defaultMessage: 'On', +}); +const TOGGLE_OFF = i18n.translate('xpack.ml.splom.toggleOff', { + defaultMessage: 'Off', +}); + +const sampleSizeOptions = [100, 1000, 10000].map((d) => ({ value: d, text: '' + d })); + +export interface ScatterplotMatrixViewProps { + fields: string[]; + index: string; + resultsField?: string; + color?: string; + legendType?: LegendType; + searchQuery?: ResultsSearchQuery; +} + +export const ScatterplotMatrixView: FC = ({ + fields: allFields, + index, + resultsField, + color, + legendType, + searchQuery, +}) => { + const { esSearch } = useMlApiContext(); + + // dynamicSize is optionally used for outlier charts where the scatterplot marks + // are sized according to outlier_score + const [dynamicSize, setDynamicSize] = useState(false); + + // used to give the use the option to customize the fields used for the matrix axes + const [fields, setFields] = useState([]); + + useEffect(() => { + const defaultFields = + allFields.length > SCATTERPLOT_MATRIX_DEFAULT_FIELDS + ? allFields.slice(0, SCATTERPLOT_MATRIX_DEFAULT_FIELDS) + : allFields; + setFields(defaultFields); + }, [allFields]); + + // the amount of documents to be fetched + const [fetchSize, setFetchSize] = useState(SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE); + // flag to add a random score to the ES query to fetch documents + const [randomizeQuery, setRandomizeQuery] = useState(false); + + const [isLoading, setIsLoading] = useState(false); + + // contains the fetched documents and columns to be passed on to the Vega spec. + const [splom, setSplom] = useState<{ items: any[]; columns: string[] } | undefined>(); + + // formats the array of field names for EuiComboBox + const fieldOptions = useMemo( + () => + allFields.map((d) => ({ + label: d, + })), + [allFields] + ); + + const fieldsOnChange = (newFields: EuiComboBoxOptionOption[]) => { + setFields(newFields.map((d) => d.label)); + }; + + const fetchSizeOnChange = (e: React.ChangeEvent) => { + setFetchSize( + Math.min( + Math.max(parseInt(e.target.value, 10), SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE), + SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE + ) + ); + }; + + const randomizeQueryOnChange = () => { + setRandomizeQuery(!randomizeQuery); + }; + + const dynamicSizeOnChange = () => { + setDynamicSize(!dynamicSize); + }; + + const { euiTheme } = useCurrentEuiTheme(); + + useEffect(() => { + async function fetchSplom(options: { didCancel: boolean }) { + setIsLoading(true); + try { + const queryFields = [ + ...fields, + ...(color !== undefined ? [color] : []), + ...(legendType !== undefined ? [] : [`${resultsField}.${OUTLIER_SCORE_FIELD}`]), + ]; + + const queryFallback = searchQuery !== undefined ? searchQuery : { match_all: {} }; + const query = randomizeQuery + ? { + function_score: { + query: queryFallback, + random_score: { seed: 10, field: '_seq_no' }, + }, + } + : queryFallback; + + const resp: SearchResponse7 = await esSearch({ + index, + body: { + fields: queryFields, + _source: false, + query, + from: 0, + size: fetchSize, + }, + }); + + if (!options.didCancel) { + const items = resp.hits.hits.map((d) => + getProcessedFields(d.fields, (key: string) => + key.startsWith(`${resultsField}.feature_importance`) + ) + ); + + setSplom({ columns: fields, items }); + setIsLoading(false); + } + } catch (e) { + // TODO error handling + setIsLoading(false); + } + } + + const options = { didCancel: false }; + fetchSplom(options); + return () => { + options.didCancel = true; + }; + // stringify the fields array and search, otherwise the comparator will trigger on new but identical instances. + }, [fetchSize, JSON.stringify({ fields, searchQuery }), index, randomizeQuery, resultsField]); + + const htmlId = useMemo(() => htmlIdGenerator()(), []); + + useEffect(() => { + if (splom === undefined) { + return; + } + + const { items, columns } = splom; + + const values = + resultsField !== undefined + ? items + : items.map((d) => { + d[`${resultsField}.${OUTLIER_SCORE_FIELD}`] = 0; + return d; + }); + + const vegaSpec = getScatterplotMatrixVegaLiteSpec( + values, + columns, + euiTheme, + resultsField, + color, + legendType, + dynamicSize + ); + + const vgSpec = compile(vegaSpec).spec; + + const view = new View(parse(vgSpec)) + .logLevel(Warn) + .renderer('canvas') + .tooltip(new Handler().call) + .initialize(`#${htmlId}`); + + view.runAsync(); // evaluate and render the view + }, [resultsField, splom, color, legendType, dynamicSize]); + + return ( + <> + {splom === undefined ? ( + + ) : ( + <> + + + + ({ + label: d, + }))} + onChange={fieldsOnChange} + isClearable={true} + data-test-subj="mlScatterplotMatrixFieldsComboBox" + /> + + + + + + + + + + + + + {resultsField !== undefined && legendType === undefined && ( + + + + + + )} + + +
+ + )} + + ); +}; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default ScatterplotMatrixView; diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/use_scatterplot_field_options.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/use_scatterplot_field_options.ts new file mode 100644 index 0000000000000..f5eedbc03951f --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/use_scatterplot_field_options.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useMemo } from 'react'; + +import type { IndexPattern } from '../../../../../../../src/plugins/data/public'; + +import { ML__INCREMENTAL_ID } from '../../data_frame_analytics/common/fields'; + +export const useScatterplotFieldOptions = ( + indexPattern?: IndexPattern, + includes?: string[], + excludes?: string[], + resultsField = '' +): string[] => { + return useMemo(() => { + const fields: string[] = []; + + if (indexPattern === undefined || includes === undefined) { + return fields; + } + + if (includes.length > 1) { + fields.push( + ...includes.filter((d) => + indexPattern.fields.some((f) => f.name === d && f.type === 'number') + ) + ); + } else { + fields.push( + ...indexPattern.fields + .filter( + (f) => + f.type === 'number' && + !indexPattern.metaFields.includes(f.name) && + !f.name.startsWith(`${resultsField}.`) && + f.name !== ML__INCREMENTAL_ID + ) + .map((f) => f.name) + ); + } + + return Array.isArray(excludes) && excludes.length > 0 + ? fields.filter((f) => !excludes.includes(f)) + : fields; + }, [indexPattern, includes, excludes]); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts new file mode 100644 index 0000000000000..8850d42577bd9 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ANALYSIS_CONFIG_TYPE } from './analytics'; + +import { AnalyticsJobType } from '../pages/analytics_management/hooks/use_create_analytics_form/state'; + +import { LEGEND_TYPES } from '../../components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec'; + +export const getScatterplotMatrixLegendType = (jobType: AnalyticsJobType | 'unknown') => { + switch (jobType) { + case ANALYSIS_CONFIG_TYPE.CLASSIFICATION: + return LEGEND_TYPES.NOMINAL; + case ANALYSIS_CONFIG_TYPE.REGRESSION: + return LEGEND_TYPES.QUANTITATIVE; + default: + return undefined; + } +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts index 7ba3e910ddd32..d03f73ad13575 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts @@ -41,6 +41,7 @@ export { export { getIndexData } from './get_index_data'; export { getIndexFields } from './get_index_fields'; +export { getScatterplotMatrixLegendType } from './get_scatterplot_matrix_legend_type'; export { useResultsViewConfig } from './use_results_view_config'; export { DataFrameAnalyticsConfig } from '../../../../common/types/data_frame_analytics'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts index 185513f75a12c..361a1262a59f8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts @@ -102,6 +102,12 @@ export const useResultsViewConfig = (jobId: string) => { try { indexP = await mlContext.indexPatterns.get(destIndexPatternId); + + // Force refreshing the fields list here because a user directly coming + // from the job creation wizard might land on the page without the + // index pattern being fully initialized because it was created + // before the analytics job populated the destination index. + await mlContext.indexPatterns.refreshFields(indexP); } catch (e) { indexP = undefined; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx index a5991f77e88e2..4b86f5ca12896 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx @@ -27,10 +27,10 @@ import { TRAINING_PERCENT_MAX, FieldSelectionItem, } from '../../../../common/analytics'; +import { getScatterplotMatrixLegendType } from '../../../../common/get_scatterplot_matrix_legend_type'; import { CreateAnalyticsStepProps } from '../../../analytics_management/hooks/use_create_analytics_form'; import { Messages } from '../shared'; import { - AnalyticsJobType, DEFAULT_MODEL_MEMORY_LIMIT, State, } from '../../../analytics_management/hooks/use_create_analytics_form/state'; @@ -51,18 +51,7 @@ import { SEARCH_QUERY_LANGUAGE } from '../../../../../../../common/constants/sea import { ExplorationQueryBarProps } from '../../../analytics_exploration/components/exploration_query_bar/exploration_query_bar'; import { Query } from '../../../../../../../../../../src/plugins/data/common/query'; -import { LEGEND_TYPES, ScatterplotMatrix } from '../../../../../components/scatterplot_matrix'; - -const getScatterplotMatrixLegendType = (jobType: AnalyticsJobType) => { - switch (jobType) { - case ANALYSIS_CONFIG_TYPE.CLASSIFICATION: - return LEGEND_TYPES.NOMINAL; - case ANALYSIS_CONFIG_TYPE.REGRESSION: - return LEGEND_TYPES.QUANTITATIVE; - default: - return undefined; - } -}; +import { ScatterplotMatrix } from '../../../../../components/scatterplot_matrix'; const requiredFieldsErrorText = i18n.translate( 'xpack.ml.dataframe.analytics.createWizard.requiredFieldsErrorMessage', @@ -498,6 +487,7 @@ export const ConfigurationStepForm: FC = ({ : undefined } legendType={getScatterplotMatrixLegendType(jobType)} + searchQuery={jobConfigQuery} /> diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx index a35a314bec985..0be9e00b70f93 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment, useEffect, useRef } from 'react'; - +import React, { FC, Fragment, useEffect, useMemo, useRef } from 'react'; +import { debounce } from 'lodash'; import { EuiCallOut, EuiCodeEditor, @@ -22,6 +22,9 @@ import { XJsonMode } from '../../../../../../../shared_imports'; const xJsonMode = new XJsonMode(); +import { useNotifications } from '../../../../../contexts/kibana'; +import { ml } from '../../../../../services/ml_api_service'; +import { extractErrorMessage } from '../../../../../../../common/util/errors'; import { CreateAnalyticsFormProps } from '../../../analytics_management/hooks/use_create_analytics_form'; import { CreateStep } from '../create_step'; import { ANALYTICS_STEPS } from '../../page'; @@ -42,11 +45,33 @@ export const CreateAnalyticsAdvancedEditor: FC = (prop } = state.form; const forceInput = useRef(null); + const { toasts } = useNotifications(); const onChange = (str: string) => { setAdvancedEditorRawString(str); }; + const debouncedJobIdCheck = useMemo( + () => + debounce(async () => { + try { + const { results } = await ml.dataFrameAnalytics.jobsExists([jobId], true); + setFormState({ jobIdExists: results[jobId] }); + } catch (e) { + toasts.addDanger( + i18n.translate( + 'xpack.ml.dataframe.analytics.create.advancedEditor.errorCheckingJobIdExists', + { + defaultMessage: 'The following error occurred checking if job id exists: {error}', + values: { error: extractErrorMessage(e) }, + } + ) + ); + } + }, 400), + [jobId] + ); + // Temp effect to close the context menu popover on Clone button click useEffect(() => { if (forceInput.current === null) { @@ -57,6 +82,18 @@ export const CreateAnalyticsAdvancedEditor: FC = (prop forceInput.current.dispatchEvent(evt); }, []); + useEffect(() => { + if (jobIdValid === true) { + debouncedJobIdCheck(); + } else if (typeof jobId === 'string' && jobId.trim() === '' && jobIdExists === true) { + setFormState({ jobIdExists: false }); + } + + return () => { + debouncedJobIdCheck.cancel(); + }; + }, [jobId]); + return ( = ({ } }, 400); + const debouncedJobIdCheck = useMemo( + () => + debounce(async () => { + try { + const { results } = await ml.dataFrameAnalytics.jobsExists([jobId], true); + setFormState({ jobIdExists: results[jobId] }); + } catch (e) { + notifications.toasts.addDanger( + i18n.translate('xpack.ml.dataframe.analytics.create.errorCheckingJobIdExists', { + defaultMessage: 'The following error occurred checking if job id exists: {error}', + values: { error: extractErrorMessage(e) }, + }) + ); + } + }, 400), + [jobId] + ); + + useEffect(() => { + if (jobIdValid === true) { + debouncedJobIdCheck(); + } else if (typeof jobId === 'string' && jobId.trim() === '' && jobIdExists === true) { + setFormState({ jobIdExists: false }); + } + + return () => { + debouncedJobIdCheck.cancel(); + }; + }, [jobId]); + useEffect(() => { if (destinationIndexNameValid === true) { debouncedIndexCheck(); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_splom.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_splom.tsx index 5ec8963e0fc25..8c51c95d7fd63 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_splom.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_splom.tsx @@ -12,17 +12,14 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; -import { ScatterplotMatrix } from '../../../../../components/scatterplot_matrix'; +import { + ScatterplotMatrix, + ScatterplotMatrixProps, +} from '../../../../../components/scatterplot_matrix'; import { ExpandableSection } from './expandable_section'; -interface ExpandableSectionSplomProps { - fields: string[]; - index: string; - resultsField?: string; -} - -export const ExpandableSectionSplom: FC = (props) => { +export const ExpandableSectionSplom: FC = (props) => { const splomSectionHeaderItems = undefined; const splomSectionContent = ( <> diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx index 1329644322f33..46715af0ef0cb 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx @@ -9,16 +9,21 @@ import React, { FC, useCallback, useState } from 'react'; import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { getAnalysisType, getDependentVar } from '../../../../../../../common/util/analytics_utils'; + +import { useScatterplotFieldOptions } from '../../../../../components/scatterplot_matrix'; + import { defaultSearchQuery, + getScatterplotMatrixLegendType, useResultsViewConfig, DataFrameAnalyticsConfig, } from '../../../../common'; -import { ResultsSearchQuery } from '../../../../common/analytics'; +import { ResultsSearchQuery, ANALYSIS_CONFIG_TYPE } from '../../../../common/analytics'; import { DataFrameTaskStateType } from '../../../analytics_management/components/analytics_list/common'; -import { ExpandableSectionAnalytics } from '../expandable_section'; +import { ExpandableSectionAnalytics, ExpandableSectionSplom } from '../expandable_section'; import { ExplorationResultsTable } from '../exploration_results_table'; import { ExplorationQueryBar } from '../exploration_query_bar'; import { JobConfigErrorCallout } from '../job_config_error_callout'; @@ -99,6 +104,14 @@ export const ExplorationPageWrapper: FC = ({ language: pageUrlState.queryLanguage, }; + const resultsField = jobConfig?.dest.results_field ?? ''; + const scatterplotFieldOptions = useScatterplotFieldOptions( + indexPattern, + jobConfig?.analyzed_fields.includes, + jobConfig?.analyzed_fields.excludes, + resultsField + ); + if (indexPatternErrorMessage !== undefined) { return ( @@ -125,6 +138,9 @@ export const ExplorationPageWrapper: FC = ({ ); } + const jobType = + jobConfig && jobConfig.analysis ? getAnalysisType(jobConfig?.analysis) : undefined; + return ( <> {typeof jobConfig?.description !== 'undefined' && ( @@ -179,6 +195,27 @@ export const ExplorationPageWrapper: FC = ({ )} + {isLoadingJobConfig === true && jobConfig === undefined && } + {isLoadingJobConfig === false && + jobConfig !== undefined && + isInitialized === true && + typeof jobConfig?.id === 'string' && + scatterplotFieldOptions.length > 1 && + typeof jobConfig?.analysis !== 'undefined' && ( + + )} + {isLoadingJobConfig === true && jobConfig === undefined && } {isLoadingJobConfig === false && jobConfig !== undefined && diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx index 26eee9bc95d73..7e11e0bd97015 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, FC, useCallback } from 'react'; +import React, { useCallback, useState, FC } from 'react'; import { EuiCallOut, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; @@ -15,6 +15,7 @@ import { COLOR_RANGE, COLOR_RANGE_SCALE, } from '../../../../../components/color_range_legend'; +import { useScatterplotFieldOptions } from '../../../../../components/scatterplot_matrix'; import { SavedSearchQuery } from '../../../../../contexts/ml'; import { defaultSearchQuery, isOutlierAnalysis, useResultsViewConfig } from '../../../../common'; @@ -90,6 +91,13 @@ export const OutlierExploration: FC = React.memo(({ jobId }) = (d) => d.id === `${resultsField}.${FEATURE_INFLUENCE}.feature_name` ) === -1; + const scatterplotFieldOptions = useScatterplotFieldOptions( + indexPattern, + jobConfig?.analyzed_fields.includes, + jobConfig?.analyzed_fields.excludes, + resultsField + ); + if (indexPatternErrorMessage !== undefined) { return ( @@ -126,11 +134,12 @@ export const OutlierExploration: FC = React.memo(({ jobId }) = )} {typeof jobConfig?.id === 'string' && } - {typeof jobConfig?.id === 'string' && jobConfig?.analyzed_fields.includes.length > 1 && ( + {typeof jobConfig?.id === 'string' && scatterplotFieldOptions.length > 1 && ( )} {showLegacyFeatureInfluenceFormatCallout && ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts index a277ae6e6a66e..998460d75f6f0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -499,7 +499,6 @@ export function reducer(state: State, action: Action): State { } if (action.payload.jobId !== undefined) { - newFormState.jobIdExists = state.jobIds.some((id) => newFormState.jobId === id); newFormState.jobIdEmpty = newFormState.jobId === ''; newFormState.jobIdValid = isJobIdValid(newFormState.jobId); newFormState.jobIdInvalidMaxLength = !!maxLengthValidator(JOB_ID_MAX_LENGTH)( @@ -542,12 +541,6 @@ export function reducer(state: State, action: Action): State { case ACTION.SET_JOB_CONFIG: return validateAdvancedEditor({ ...state, jobConfig: action.payload }); - case ACTION.SET_JOB_IDS: { - const newState = { ...state, jobIds: action.jobIds }; - newState.form.jobIdExists = newState.jobIds.some((id) => newState.form.jobId === id); - return newState; - } - case ACTION.SWITCH_TO_ADVANCED_EDITOR: const jobConfig = getJobConfigFromFormState(state.form); const shouldDisableSwitchToForm = isAdvancedConfig(jobConfig); @@ -562,7 +555,7 @@ export function reducer(state: State, action: Action): State { }); case ACTION.SWITCH_TO_FORM: - const { jobConfig: config, jobIds } = state; + const { jobConfig: config } = state; const { jobId } = state.form; // @ts-ignore const formState = getFormStateFromJobConfig(config, false); @@ -571,7 +564,6 @@ export function reducer(state: State, action: Action): State { formState.jobId = jobId; } - formState.jobIdExists = jobIds.some((id) => formState.jobId === id); formState.jobIdEmpty = jobId === ''; formState.jobIdValid = isJobIdValid(jobId); formState.jobIdInvalidMaxLength = !!maxLengthValidator(JOB_ID_MAX_LENGTH)(jobId); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts index 0b88f52e555c0..f5bfd3075f26b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts @@ -14,11 +14,7 @@ import { ml } from '../../../../../services/ml_api_service'; import { useMlContext } from '../../../../../contexts/ml'; import { DuplicateIndexPatternError } from '../../../../../../../../../../src/plugins/data/public'; -import { - useRefreshAnalyticsList, - DataFrameAnalyticsId, - DataFrameAnalyticsConfig, -} from '../../../../common'; +import { useRefreshAnalyticsList, DataFrameAnalyticsConfig } from '../../../../common'; import { extractCloningConfig, isAdvancedConfig } from '../../components/action_clone'; import { ActionDispatchers, ACTION } from './actions'; @@ -80,9 +76,6 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { dispatch({ type: ACTION.SET_IS_JOB_STARTED, isJobStarted }); }; - const setJobIds = (jobIds: DataFrameAnalyticsId[]) => - dispatch({ type: ACTION.SET_JOB_IDS, jobIds }); - const resetRequestMessages = () => dispatch({ type: ACTION.RESET_REQUEST_MESSAGES }); const resetForm = () => dispatch({ type: ACTION.RESET_FORM }); @@ -180,25 +173,6 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { }; const prepareFormValidation = async () => { - // re-fetch existing analytics job IDs and indices for form validation - try { - setJobIds( - (await ml.dataFrameAnalytics.getDataFrameAnalytics()).data_frame_analytics.map( - (job: DataFrameAnalyticsConfig) => job.id - ) - ); - } catch (e) { - addRequestMessage({ - error: extractErrorMessage(e), - message: i18n.translate( - 'xpack.ml.dataframe.analytics.create.errorGettingDataFrameAnalyticsList', - { - defaultMessage: 'An error occurred getting the existing data frame analytics job IDs:', - } - ), - }); - } - try { // Set the existing index pattern titles. const indexPatternsMap: SourceIndexMap = {}; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_embedded_map.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_embedded_map.tsx new file mode 100644 index 0000000000000..fc1621e962f36 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_embedded_map.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { Dictionary } from '../../../../common/types/common'; +import { LayerDescriptor } from '../../../../../maps/common/descriptor_types'; +import { getMLAnomaliesActualLayer, getMLAnomaliesTypicalLayer } from './map_config'; +import { MlEmbeddedMapComponent } from '../../components/ml_embedded_map'; +interface Props { + seriesConfig: Dictionary; +} + +export function EmbeddedMapComponentWrapper({ seriesConfig }: Props) { + const [layerList, setLayerList] = useState([]); + + useEffect(() => { + if (seriesConfig.mapData && seriesConfig.mapData.length > 0) { + setLayerList([ + getMLAnomaliesActualLayer(seriesConfig.mapData), + getMLAnomaliesTypicalLayer(seriesConfig.mapData), + ]); + } + }, [seriesConfig]); + + return ( +
+ +
+ ); +} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index 774372f678c9b..9921b5f991844 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -22,6 +22,7 @@ import { } from '../../util/chart_utils'; import { ExplorerChartDistribution } from './explorer_chart_distribution'; import { ExplorerChartSingleMetric } from './explorer_chart_single_metric'; +import { EmbeddedMapComponentWrapper } from './explorer_chart_embedded_map'; import { ExplorerChartLabel } from './components/explorer_chart_label'; import { CHART_TYPE } from '../explorer_constants'; @@ -30,6 +31,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { MlTooltipComponent } from '../../components/chart_tooltip'; import { withKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ML_APP_URL_GENERATOR } from '../../../../common/constants/ml_url_generator'; +import { ML_JOB_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { addItemToRecentlyAccessed } from '../../util/recently_accessed'; import { ExplorerChartsErrorCallOuts } from './explorer_charts_error_callouts'; @@ -43,6 +45,9 @@ const textViewButton = i18n.translate( defaultMessage: 'Open in Single Metric Viewer', } ); +const mapsPluginMessage = i18n.translate('xpack.ml.explorer.charts.mapsPluginMissingMessage', { + defaultMessage: 'maps or embeddable start plugin not found', +}); // create a somewhat unique ID // from charts metadata for React's key attribute @@ -67,8 +72,8 @@ function ExplorerChartContainer({ useEffect(() => { let isCancelled = false; const generateLink = async () => { - const singleMetricViewerLink = await getExploreSeriesLink(mlUrlGenerator, series); - if (!isCancelled) { + if (!isCancelled && series.functionDescription !== ML_JOB_AGGREGATION.LAT_LONG) { + const singleMetricViewerLink = await getExploreSeriesLink(mlUrlGenerator, series); setExplorerSeriesLink(singleMetricViewerLink); } }; @@ -150,6 +155,18 @@ function ExplorerChartContainer({ {(() => { + if (chartType === CHART_TYPE.GEO_MAP) { + return ( + + {(tooltipService) => ( + + )} + + ); + } if ( chartType === CHART_TYPE.EVENT_DISTRIBUTION || chartType === CHART_TYPE.POPULATION_DISTRIBUTION @@ -167,18 +184,20 @@ function ExplorerChartContainer({ ); } - return ( - - {(tooltipService) => ( - - )} - - ); + if (chartType === CHART_TYPE.SINGLE_METRIC) { + return ( + + {(tooltipService) => ( + + )} + + ); + } })()} ); @@ -199,8 +218,31 @@ export const ExplorerChartsContainerUI = ({ share: { urlGenerators: { getUrlGenerator }, }, + embeddable: embeddablePlugin, + maps: mapsPlugin, }, } = kibana; + + let seriesToPlotFiltered; + + if (!embeddablePlugin || !mapsPlugin) { + seriesToPlotFiltered = []; + // Show missing plugin callout + seriesToPlot.forEach((series) => { + if (series.functionDescription === 'lat_long') { + if (errorMessages[mapsPluginMessage] === undefined) { + errorMessages[mapsPluginMessage] = new Set([series.jobId]); + } else { + errorMessages[mapsPluginMessage].add(series.jobId); + } + } else { + seriesToPlotFiltered.push(series); + } + }); + } + + const seriesToUse = seriesToPlotFiltered !== undefined ? seriesToPlotFiltered : seriesToPlot; + const mlUrlGenerator = useMemo(() => getUrlGenerator(ML_APP_URL_GENERATOR), [getUrlGenerator]); // doesn't allow a setting of `columns={1}` when chartsPerRow would be 1. @@ -208,13 +250,13 @@ export const ExplorerChartsContainerUI = ({ const chartsWidth = chartsPerRow === 1 ? 'calc(100% - 20px)' : 'auto'; const chartsColumns = chartsPerRow === 1 ? 0 : chartsPerRow; - const wrapLabel = seriesToPlot.some((series) => isLabelLengthAboveThreshold(series)); + const wrapLabel = seriesToUse.some((series) => isLabelLengthAboveThreshold(series)); return ( <> - {seriesToPlot.length > 0 && - seriesToPlot.map((series) => ( + {seriesToUse.length > 0 && + seriesToUse.map((series) => ( + (record.function_description || recordsToPlot.function) === ML_JOB_AGGREGATION.LAT_LONG + ); + const seriesConfigs = recordsToPlot.map(buildConfig); + const seriesConfigsNoGeoData = []; + + // initialize the charts with loading indicators + data.seriesToPlot = seriesConfigs.map((config) => ({ + ...config, + loading: true, + chartData: null, + })); + + const mapData = []; + + if (hasGeoData !== undefined) { + for (let i = 0; i < seriesConfigs.length; i++) { + const config = seriesConfigs[i]; + let records; + if (config.detectorLabel.includes(ML_JOB_AGGREGATION.LAT_LONG)) { + if (config.entityFields.length) { + records = [ + recordsToPlot.find((record) => { + const entityFieldName = config.entityFields[0].fieldName; + const entityFieldValue = config.entityFields[0].fieldValue; + return (record[entityFieldName] && record[entityFieldName][0]) === entityFieldValue; + }), + ]; + } else { + records = recordsToPlot; + } + + mapData.push({ + ...config, + loading: false, + mapData: records, + }); + } else { + seriesConfigsNoGeoData.push(config); + } + } + } // Calculate the time range of the charts, which is a function of the chart width and max job bucket span. data.tooManyBuckets = false; @@ -92,13 +137,6 @@ export const anomalyDataChange = function ( ); data.tooManyBuckets = tooManyBuckets; - // initialize the charts with loading indicators - data.seriesToPlot = seriesConfigs.map((config) => ({ - ...config, - loading: true, - chartData: null, - })); - data.errorMessages = errorMessages; explorerService.setCharts({ ...data }); @@ -269,22 +307,27 @@ export const anomalyDataChange = function ( // only after that trigger data processing and page render. // TODO - if query returns no results e.g. source data has been deleted, // display a message saying 'No data between earliest/latest'. - const seriesPromises = seriesConfigs.map((seriesConfig) => - Promise.all([ - getMetricData(seriesConfig, chartRange), - getRecordsForCriteria(seriesConfig, chartRange), - getScheduledEvents(seriesConfig, chartRange), - getEventDistribution(seriesConfig, chartRange), - ]) - ); + const seriesPromises = []; + // Use seriesConfigs list without geo data config so indices match up after seriesPromises are resolved and we map through the responses + const seriesCongifsForPromises = hasGeoData ? seriesConfigsNoGeoData : seriesConfigs; + seriesCongifsForPromises.forEach((seriesConfig) => { + seriesPromises.push( + Promise.all([ + getMetricData(seriesConfig, chartRange), + getRecordsForCriteria(seriesConfig, chartRange), + getScheduledEvents(seriesConfig, chartRange), + getEventDistribution(seriesConfig, chartRange), + ]) + ); + }); function processChartData(response, seriesIndex) { const metricData = response[0].results; const records = response[1].records; - const jobId = seriesConfigs[seriesIndex].jobId; + const jobId = seriesCongifsForPromises[seriesIndex].jobId; const scheduledEvents = response[2].events[jobId]; const eventDistribution = response[3]; - const chartType = getChartType(seriesConfigs[seriesIndex]); + const chartType = getChartType(seriesCongifsForPromises[seriesIndex]); // Sort records in ascending time order matching up with chart data records.sort((recordA, recordB) => { @@ -409,16 +452,25 @@ export const anomalyDataChange = function ( ); const overallChartLimits = chartLimits(allDataPoints); - data.seriesToPlot = response.map((d, i) => ({ - ...seriesConfigs[i], - loading: false, - chartData: processedData[i], - plotEarliest: chartRange.min, - plotLatest: chartRange.max, - selectedEarliest: selectedEarliestMs, - selectedLatest: selectedLatestMs, - chartLimits: USE_OVERALL_CHART_LIMITS ? overallChartLimits : chartLimits(processedData[i]), - })); + data.seriesToPlot = response.map((d, i) => { + return { + ...seriesCongifsForPromises[i], + loading: false, + chartData: processedData[i], + plotEarliest: chartRange.min, + plotLatest: chartRange.max, + selectedEarliest: selectedEarliestMs, + selectedLatest: selectedLatestMs, + chartLimits: USE_OVERALL_CHART_LIMITS + ? overallChartLimits + : chartLimits(processedData[i]), + }; + }); + + if (mapData.length) { + // push map data in if it's available + data.seriesToPlot.push(...mapData); + } explorerService.setCharts({ ...data }); }) .catch((error) => { @@ -447,7 +499,10 @@ function processRecordsForDisplay(anomalyRecords) { return; } - let isChartable = isSourceDataChartableForDetector(job, record.detector_index); + let isChartable = + isSourceDataChartableForDetector(job, record.detector_index) || + isMappableJob(job, record.detector_index); + if (isChartable === false) { if (isModelPlotChartableForDetector(job, record.detector_index)) { // Check if model plot is enabled for this job. diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts b/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts new file mode 100644 index 0000000000000..451fa602315d7 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FIELD_ORIGIN, STYLE_TYPE } from '../../../../../maps/common/constants'; +import { ANOMALY_THRESHOLD, SEVERITY_COLORS } from '../../../../common'; + +const FEATURE = 'Feature'; +const POINT = 'Point'; +const SEVERITY_COLOR_RAMP = [ + { + stop: ANOMALY_THRESHOLD.LOW, + color: SEVERITY_COLORS.WARNING, + }, + { + stop: ANOMALY_THRESHOLD.MINOR, + color: SEVERITY_COLORS.MINOR, + }, + { + stop: ANOMALY_THRESHOLD.MAJOR, + color: SEVERITY_COLORS.MAJOR, + }, + { + stop: ANOMALY_THRESHOLD.CRITICAL, + color: SEVERITY_COLORS.CRITICAL, + }, +]; + +function getAnomalyFeatures(anomalies: any[], type: 'actual_point' | 'typical_point') { + const anomalyFeatures = []; + for (let i = 0; i < anomalies.length; i++) { + const anomaly = anomalies[i]; + const geoResults = anomaly.geo_results || (anomaly?.causes && anomaly?.causes[0]?.geo_results); + const coordinateStr = geoResults && geoResults[type]; + if (coordinateStr !== undefined) { + // Must reverse coordinates here. Map expects [lon, lat] - anomalies are stored as [lat, lon] for lat_lon jobs + const coordinates = coordinateStr + .split(',') + .map((point: string) => Number(point)) + .reverse(); + + anomalyFeatures.push({ + type: FEATURE, + geometry: { + type: POINT, + coordinates, + }, + properties: { + record_score: Math.floor(anomaly.record_score), + [type]: coordinates.map((point: number) => point.toFixed(2)), + }, + }); + } + } + return anomalyFeatures; +} + +export const getMLAnomaliesTypicalLayer = (anomalies: any) => { + return { + id: 'anomalies_typical_layer', + label: 'Typical', + sourceDescriptor: { + id: 'b7486535-171b-4d3b-bb2e-33c1a0a2854e', + type: 'GEOJSON_FILE', + __featureCollection: { + features: getAnomalyFeatures(anomalies, 'typical_point'), + type: 'FeatureCollection', + }, + }, + visible: true, + style: { + type: 'VECTOR', + properties: { + fillColor: { + type: 'STATIC', + options: { + color: '#98A2B2', + }, + }, + lineColor: { + type: 'STATIC', + options: { + color: '#fff', + }, + }, + lineWidth: { + type: 'STATIC', + options: { + size: 2, + }, + }, + iconSize: { + type: 'STATIC', + options: { + size: 6, + }, + }, + }, + }, + type: 'VECTOR', + }; +}; + +export const getMLAnomaliesActualLayer = (anomalies: any) => { + return { + id: 'anomalies_actual_layer', + label: 'Actual', + sourceDescriptor: { + id: 'b7486535-171b-4d3b-bb2e-33c1a0a2854d', + type: 'GEOJSON_FILE', + __fields: [ + { + name: 'record_score', + type: 'number', + }, + ], + __featureCollection: { + features: getAnomalyFeatures(anomalies, 'actual_point'), + type: 'FeatureCollection', + }, + }, + visible: true, + style: { + type: 'VECTOR', + properties: { + fillColor: { + type: STYLE_TYPE.DYNAMIC, + options: { + customColorRamp: SEVERITY_COLOR_RAMP, + field: { + name: 'record_score', + origin: FIELD_ORIGIN.SOURCE, + }, + useCustomColorRamp: true, + }, + }, + lineColor: { + type: 'STATIC', + options: { + color: '#fff', + }, + }, + lineWidth: { + type: 'STATIC', + options: { + size: 2, + }, + }, + iconSize: { + type: 'STATIC', + options: { + size: 6, + }, + }, + }, + }, + type: 'VECTOR', + }; +}; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts index 3f5f016fc365a..2178c837458e9 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts @@ -48,6 +48,7 @@ export const CHART_TYPE = { EVENT_DISTRIBUTION: 'event_distribution', POPULATION_DISTRIBUTION: 'population_distribution', SINGLE_METRIC: 'single_metric', + GEO_MAP: 'geo_map', }; export const MAX_CATEGORY_EXAMPLES = 10; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js index f6889c9a6f24c..4ba9d4ea14f10 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js @@ -511,6 +511,7 @@ export async function loadAnomaliesTableData( const entityFields = getEntityFieldList(anomaly.source); isChartable = isModelPlotEnabled(job, anomaly.detectorIndex, entityFields); } + anomaly.isTimeSeriesViewRecord = isChartable; if (mlJobService.customUrlsByJob[jobId] !== undefined) { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx index 280ac85a5a2bc..470fe11759d27 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx @@ -46,7 +46,7 @@ export const FieldDescription: FC = memo(({ children }) => { description={ } > diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts index 298dcad4ce488..7b246e557d7a5 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts @@ -45,6 +45,11 @@ interface DeleteDataFrameAnalyticsWithIndexResponse { destIndexDeleted: DeleteDataFrameAnalyticsWithIndexStatus; destIndexPatternDeleted: DeleteDataFrameAnalyticsWithIndexStatus; } +interface JobsExistsResponse { + results: { + [jobId: string]: boolean; + }; +} export const dataFrameAnalytics = { getDataFrameAnalytics(analyticsId?: string) { @@ -98,6 +103,14 @@ export const dataFrameAnalytics = { query: { treatAsRoot, type }, }); }, + jobsExists(analyticsIds: string[], allSpaces: boolean = false) { + const body = JSON.stringify({ analyticsIds, allSpaces }); + return http({ + path: `${basePath()}/data_frame/analytics/jobs_exist`, + method: 'POST', + body, + }); + }, evaluateDataFrameAnalytics(evaluateConfig: any) { const body = JSON.stringify(evaluateConfig); return http({ diff --git a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts index 514449385bf0b..3747e84f43765 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts @@ -156,7 +156,8 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { } body.aggs.byTime.aggs = {}; - if (metricFieldName !== undefined && metricFieldName !== '') { + + if (metricFieldName !== undefined && metricFieldName !== '' && metricFunction) { const metricAgg: any = { [metricFunction]: {}, }; diff --git a/x-pack/plugins/ml/public/application/util/chart_config_builder.js b/x-pack/plugins/ml/public/application/util/chart_config_builder.js index a30280f1220c0..a306211defc87 100644 --- a/x-pack/plugins/ml/public/application/util/chart_config_builder.js +++ b/x-pack/plugins/ml/public/application/util/chart_config_builder.js @@ -24,7 +24,10 @@ export function buildConfigFromDetector(job, detectorIndex) { const config = { jobId: job.job_id, detectorIndex: detectorIndex, - metricFunction: mlFunctionToESAggregation(detector.function), + metricFunction: + detector.function === ML_JOB_AGGREGATION.LAT_LONG + ? ML_JOB_AGGREGATION.LAT_LONG + : mlFunctionToESAggregation(detector.function), timeField: job.data_description.time_field, interval: job.analysis_config.bucket_span, datafeedConfig: job.datafeed_config, diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.js b/x-pack/plugins/ml/public/application/util/chart_utils.js index 402c922a0034f..799187cc37dfd 100644 --- a/x-pack/plugins/ml/public/application/util/chart_utils.js +++ b/x-pack/plugins/ml/public/application/util/chart_utils.js @@ -176,6 +176,11 @@ const POPULATION_DISTRIBUTION_ENABLED = true; // get the chart type based on its configuration export function getChartType(config) { let chartType = CHART_TYPE.SINGLE_METRIC; + + if (config.functionDescription === 'lat_long' || config.mapData !== undefined) { + return CHART_TYPE.GEO_MAP; + } + if ( EVENT_DISTRIBUTION_ENABLED && config.functionDescription === 'rare' && diff --git a/x-pack/plugins/observability/public/components/shared/action_menu/index.tsx b/x-pack/plugins/observability/public/components/shared/action_menu/index.tsx index 4819a0760d88a..af61f618a89b2 100644 --- a/x-pack/plugins/observability/public/components/shared/action_menu/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/action_menu/index.tsx @@ -26,7 +26,7 @@ export function SectionTitle({ children }: { children?: ReactNode }) {
{children}
- + ); } @@ -55,7 +55,7 @@ export function SectionSpacer() { } export const Section = styled.div` - margin-bottom: 24px; + margin-bottom: 16px; &:last-of-type { margin-bottom: 0; } @@ -63,7 +63,7 @@ export const Section = styled.div` export type SectionLinkProps = EuiListGroupItemProps; export function SectionLink(props: SectionLinkProps) { - return ; + return ; } export function ActionMenuDivider() { diff --git a/x-pack/plugins/painless_lab/tsconfig.json b/x-pack/plugins/painless_lab/tsconfig.json new file mode 100644 index 0000000000000..a869b21e06d4d --- /dev/null +++ b/x-pack/plugins/painless_lab/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/dev_tools/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" } + ] +} diff --git a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts index b154978d041f4..7706aa9d650c7 100644 --- a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts +++ b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts @@ -13,7 +13,7 @@ import { ReportDocument } from '../../lib/store'; import { TaskRunResult } from '../../lib/tasks'; import { ExportTypeDefinition } from '../../types'; -interface ErrorFromPayload { +export interface ErrorFromPayload { message: string; } diff --git a/x-pack/plugins/reporting/tsconfig.json b/x-pack/plugins/reporting/tsconfig.json new file mode 100644 index 0000000000000..88e8d343f4700 --- /dev/null +++ b/x-pack/plugins/reporting/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "../../../typings/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json"}, + { "path": "../../../src/plugins/discover/tsconfig.json" }, + { "path": "../../../src/plugins/embeddable/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/management/tsconfig.json" }, + { "path": "../../../src/plugins/share/tsconfig.json" }, + { "path": "../../../src/plugins/ui_actions/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, + { "path": "../spaces/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts b/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts index 8b5871a6a67db..857582aac7638 100644 --- a/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts @@ -28,7 +28,9 @@ import { populateTimeline } from '../../tasks/timeline'; import { SERVER_SIDE_EVENT_COUNT } from '../../screens/timeline'; import { cleanKibana } from '../../tasks/common'; -describe('Sourcerer', () => { +// Skipped at the moment as this has flake due to click handler issues. This has been raised with team members +// and the code is being re-worked and then these tests will be unskipped +describe.skip('Sourcerer', () => { before(() => { cleanKibana(); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts index 37123dedfd661..2c9dc14aa05b2 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts @@ -5,7 +5,7 @@ */ import { formatMitreAttackDescription } from '../../helpers/rules'; -import { newThreatIndicatorRule } from '../../objects/rule'; +import { indexPatterns, newThreatIndicatorRule } from '../../objects/rule'; import { ALERT_RULE_METHOD, @@ -70,7 +70,24 @@ import { createAndActivateRule, fillAboutRuleAndContinue, fillDefineIndicatorMatchRuleAndContinue, + fillIndexAndIndicatorIndexPattern, + fillIndicatorMatchRow, fillScheduleRuleAndContinue, + getCustomIndicatorQueryInput, + getCustomQueryInput, + getCustomQueryInvalidationText, + getDefineContinueButton, + getIndexPatternClearButton, + getIndexPatternInvalidationText, + getIndicatorAndButton, + getIndicatorAtLeastOneInvalidationText, + getIndicatorDeleteButton, + getIndicatorIndex, + getIndicatorIndexComboField, + getIndicatorIndicatorIndex, + getIndicatorInvalidationText, + getIndicatorMappingComboField, + getIndicatorOrButton, selectIndicatorMatchType, waitForAlertsToPopulate, waitForTheRuleToBeExecuted, @@ -92,14 +109,6 @@ describe('Detection rules, Indicator Match', () => { cleanKibana(); esArchiverLoad('threat_indicator'); esArchiverLoad('threat_data'); - }); - - afterEach(() => { - esArchiverUnload('threat_indicator'); - esArchiverUnload('threat_data'); - }); - - it('Creates and activates a new Indicator Match rule', () => { loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); waitForAlertsPanelToBeLoaded(); waitForAlertsIndexToBeCreated(); @@ -107,89 +116,330 @@ describe('Detection rules, Indicator Match', () => { waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); goToCreateNewRule(); selectIndicatorMatchType(); - fillDefineIndicatorMatchRuleAndContinue(newThreatIndicatorRule); - fillAboutRuleAndContinue(newThreatIndicatorRule); - fillScheduleRuleAndContinue(newThreatIndicatorRule); - createAndActivateRule(); + }); + + afterEach(() => { + esArchiverUnload('threat_indicator'); + esArchiverUnload('threat_data'); + }); - cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)'); + describe('Creating new indicator match rules', () => { + describe('Index patterns', () => { + it('Contains a predefined index pattern', () => { + getIndicatorIndex().should('have.text', indexPatterns.join('')); + }); - changeToThreeHundredRowsPerPage(); - waitForRulesToBeLoaded(); + it('Does NOT show invalidation text on initial page load if indicator index pattern is filled out', () => { + getIndicatorIndicatorIndex().type(`${newThreatIndicatorRule.indicatorIndexPattern}{enter}`); + getDefineContinueButton().click(); + getIndexPatternInvalidationText().should('not.exist'); + }); - cy.get(RULES_TABLE).then(($table) => { - cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); + it('Shows invalidation text when you try to continue without filling it out', () => { + getIndexPatternClearButton().click(); + getIndicatorIndicatorIndex().type(`${newThreatIndicatorRule.indicatorIndexPattern}{enter}`); + getDefineContinueButton().click(); + getIndexPatternInvalidationText().should('exist'); + }); }); - filterByCustomRules(); + describe('Indicator index patterns', () => { + it('Contains empty index pattern', () => { + getIndicatorIndicatorIndex().should('have.text', ''); + }); + + it('Does NOT show invalidation text on initial page load', () => { + getIndexPatternInvalidationText().should('not.exist'); + }); - cy.get(RULES_TABLE).then(($table) => { - cy.wrap($table.find(RULES_ROW).length).should('eql', 1); + it('Shows invalidation text if you try to continue without filling it out', () => { + getDefineContinueButton().click(); + getIndexPatternInvalidationText().should('exist'); + }); }); - cy.get(RULE_NAME).should('have.text', newThreatIndicatorRule.name); - cy.get(RISK_SCORE).should('have.text', newThreatIndicatorRule.riskScore); - cy.get(SEVERITY).should('have.text', newThreatIndicatorRule.severity); - cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true'); - - goToRuleDetails(); - - cy.get(RULE_NAME_HEADER).should('have.text', `${newThreatIndicatorRule.name}`); - cy.get(ABOUT_RULE_DESCRIPTION).should('have.text', newThreatIndicatorRule.description); - cy.get(ABOUT_DETAILS).within(() => { - getDetails(SEVERITY_DETAILS).should('have.text', newThreatIndicatorRule.severity); - getDetails(RISK_SCORE_DETAILS).should('have.text', newThreatIndicatorRule.riskScore); - getDetails(REFERENCE_URLS_DETAILS).should((details) => { - expect(removeExternalLinkText(details.text())).equal(expectedUrls); - }); - getDetails(FALSE_POSITIVES_DETAILS).should('have.text', expectedFalsePositives); - getDetails(MITRE_ATTACK_DETAILS).should((mitre) => { - expect(removeExternalLinkText(mitre.text())).equal(expectedMitre); - }); - getDetails(TAGS_DETAILS).should('have.text', expectedTags); + + describe('custom query input', () => { + it('Has a default set of *:*', () => { + getCustomQueryInput().should('have.text', '*:*'); + }); + + it('Shows invalidation text if text is removed', () => { + getCustomQueryInput().type('{selectall}{del}'); + getCustomQueryInvalidationText().should('exist'); + }); }); - cy.get(INVESTIGATION_NOTES_TOGGLE).click({ force: true }); - cy.get(ABOUT_INVESTIGATION_NOTES).should('have.text', INVESTIGATION_NOTES_MARKDOWN); - - cy.get(DEFINITION_DETAILS).within(() => { - getDetails(INDEX_PATTERNS_DETAILS).should( - 'have.text', - newThreatIndicatorRule.index!.join('') - ); - getDetails(CUSTOM_QUERY_DETAILS).should('have.text', '*:*'); - getDetails(RULE_TYPE_DETAILS).should('have.text', 'Indicator Match'); - getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', 'None'); - getDetails(INDICATOR_INDEX_PATTERNS).should( - 'have.text', - newThreatIndicatorRule.indicatorIndexPattern.join('') - ); - getDetails(INDICATOR_MAPPING).should( - 'have.text', - `${newThreatIndicatorRule.indicatorMapping} MATCHES ${newThreatIndicatorRule.indicatorIndexField}` - ); - getDetails(INDICATOR_INDEX_QUERY).should('have.text', '*:*'); + + describe('custom indicator query input', () => { + it('Has a default set of *:*', () => { + getCustomIndicatorQueryInput().should('have.text', '*:*'); + }); + + it('Shows invalidation text if text is removed', () => { + getCustomIndicatorQueryInput().type('{selectall}{del}'); + getCustomQueryInvalidationText().should('exist'); + }); }); - cy.get(SCHEDULE_DETAILS).within(() => { - getDetails(RUNS_EVERY_DETAILS).should( - 'have.text', - `${newThreatIndicatorRule.runsEvery.interval}${newThreatIndicatorRule.runsEvery.type}` - ); - getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should( - 'have.text', - `${newThreatIndicatorRule.lookBack.interval}${newThreatIndicatorRule.lookBack.type}` - ); + describe('Indicator mapping', () => { + beforeEach(() => { + fillIndexAndIndicatorIndexPattern( + newThreatIndicatorRule.index, + newThreatIndicatorRule.indicatorIndexPattern + ); + }); + + it('Does NOT show invalidation text on initial page load', () => { + getIndicatorInvalidationText().should('not.exist'); + }); + + it('Shows invalidation text when you try to press continue without filling anything out', () => { + getDefineContinueButton().click(); + getIndicatorAtLeastOneInvalidationText().should('exist'); + }); + + it('Shows invalidation text when the "AND" button is pressed and both the mappings are blank', () => { + getIndicatorAndButton().click(); + getIndicatorInvalidationText().should('exist'); + }); + + it('Shows invalidation text when the "OR" button is pressed and both the mappings are blank', () => { + getIndicatorOrButton().click(); + getIndicatorInvalidationText().should('exist'); + }); + + it('Does NOT show invalidation text when there is a valid "index field" and a valid "indicator index field"', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getDefineContinueButton().click(); + getIndicatorInvalidationText().should('not.exist'); + }); + + it('Shows invalidation text when there is an invalid "index field" and a valid "indicator index field"', () => { + fillIndicatorMatchRow({ + indexField: 'non-existent-value', + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + validColumns: 'indicatorField', + }); + getDefineContinueButton().click(); + getIndicatorInvalidationText().should('exist'); + }); + + it('Shows invalidation text when there is a valid "index field" and an invalid "indicator index field"', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: 'non-existent-value', + validColumns: 'indexField', + }); + getDefineContinueButton().click(); + getIndicatorInvalidationText().should('exist'); + }); + + it('Deletes the first row when you have two rows. Both rows valid rows of "index fields" and valid "indicator index fields". The second row should become the first row', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getIndicatorAndButton().click(); + fillIndicatorMatchRow({ + rowNumber: 2, + indexField: 'agent.name', + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + validColumns: 'indicatorField', + }); + getIndicatorDeleteButton().click(); + getIndicatorIndexComboField().should('have.text', 'agent.name'); + getIndicatorMappingComboField().should( + 'have.text', + newThreatIndicatorRule.indicatorIndexField + ); + getIndicatorIndexComboField(2).should('not.exist'); + getIndicatorMappingComboField(2).should('not.exist'); + }); + + it('Deletes the first row when you have two rows. Both rows have valid "index fields" and invalid "indicator index fields". The second row should become the first row', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: 'non-existent-value', + validColumns: 'indexField', + }); + getIndicatorAndButton().click(); + fillIndicatorMatchRow({ + rowNumber: 2, + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: 'second-non-existent-value', + validColumns: 'indexField', + }); + getIndicatorDeleteButton().click(); + getIndicatorMappingComboField().should('have.text', 'second-non-existent-value'); + getIndicatorIndexComboField(2).should('not.exist'); + getIndicatorMappingComboField(2).should('not.exist'); + }); + + it('Deletes the first row when you have two rows. Both rows have valid "indicator index fields" and invalid "index fields". The second row should become the first row', () => { + fillIndicatorMatchRow({ + indexField: 'non-existent-value', + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + validColumns: 'indicatorField', + }); + getIndicatorAndButton().click(); + fillIndicatorMatchRow({ + rowNumber: 2, + indexField: 'second-non-existent-value', + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + validColumns: 'indicatorField', + }); + getIndicatorDeleteButton().click(); + getIndicatorIndexComboField().should('have.text', 'second-non-existent-value'); + getIndicatorIndexComboField(2).should('not.exist'); + getIndicatorMappingComboField(2).should('not.exist'); + }); + + it('Deletes the first row of data but not the UI elements and the text defaults back to the placeholder of Search', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getIndicatorDeleteButton().click(); + getIndicatorIndexComboField().should('text', 'Search'); + getIndicatorMappingComboField().should('text', 'Search'); + getIndicatorIndexComboField(2).should('not.exist'); + getIndicatorMappingComboField(2).should('not.exist'); + }); + + it('Deletes the second row when you have three rows. The first row is valid data, the second row is invalid data, and the third row is valid data. Third row should shift up correctly', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getIndicatorAndButton().click(); + fillIndicatorMatchRow({ + rowNumber: 2, + indexField: 'non-existent-value', + indicatorIndexField: 'non-existent-value', + validColumns: 'none', + }); + getIndicatorAndButton().click(); + fillIndicatorMatchRow({ + rowNumber: 3, + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getIndicatorDeleteButton(2).click(); + getIndicatorIndexComboField(1).should('text', newThreatIndicatorRule.indicatorMapping); + getIndicatorMappingComboField(1).should('text', newThreatIndicatorRule.indicatorIndexField); + getIndicatorIndexComboField(2).should('text', newThreatIndicatorRule.indicatorMapping); + getIndicatorMappingComboField(2).should('text', newThreatIndicatorRule.indicatorIndexField); + getIndicatorIndexComboField(3).should('not.exist'); + getIndicatorMappingComboField(3).should('not.exist'); + }); + + it('Can add two OR rows and delete the second row. The first row has invalid data and the second row has valid data. The first row is deleted and the second row shifts up correctly.', () => { + fillIndicatorMatchRow({ + indexField: 'non-existent-value-one', + indicatorIndexField: 'non-existent-value-two', + validColumns: 'none', + }); + getIndicatorOrButton().click(); + fillIndicatorMatchRow({ + rowNumber: 2, + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getIndicatorDeleteButton().click(); + getIndicatorIndexComboField().should('text', newThreatIndicatorRule.indicatorMapping); + getIndicatorMappingComboField().should('text', newThreatIndicatorRule.indicatorIndexField); + getIndicatorIndexComboField(2).should('not.exist'); + getIndicatorMappingComboField(2).should('not.exist'); + }); }); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); - - cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts); - cy.get(ALERT_RULE_NAME).first().should('have.text', newThreatIndicatorRule.name); - cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); - cy.get(ALERT_RULE_METHOD).first().should('have.text', 'threat_match'); - cy.get(ALERT_RULE_SEVERITY) - .first() - .should('have.text', newThreatIndicatorRule.severity.toLowerCase()); - cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', newThreatIndicatorRule.riskScore); + it('Creates and activates a new Indicator Match rule', () => { + fillDefineIndicatorMatchRuleAndContinue(newThreatIndicatorRule); + fillAboutRuleAndContinue(newThreatIndicatorRule); + fillScheduleRuleAndContinue(newThreatIndicatorRule); + createAndActivateRule(); + + cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)'); + + changeToThreeHundredRowsPerPage(); + waitForRulesToBeLoaded(); + + cy.get(RULES_TABLE).then(($table) => { + cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); + }); + + filterByCustomRules(); + + cy.get(RULES_TABLE).then(($table) => { + cy.wrap($table.find(RULES_ROW).length).should('eql', 1); + }); + cy.get(RULE_NAME).should('have.text', newThreatIndicatorRule.name); + cy.get(RISK_SCORE).should('have.text', newThreatIndicatorRule.riskScore); + cy.get(SEVERITY).should('have.text', newThreatIndicatorRule.severity); + cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true'); + + goToRuleDetails(); + + cy.get(RULE_NAME_HEADER).should('have.text', `${newThreatIndicatorRule.name}`); + cy.get(ABOUT_RULE_DESCRIPTION).should('have.text', newThreatIndicatorRule.description); + cy.get(ABOUT_DETAILS).within(() => { + getDetails(SEVERITY_DETAILS).should('have.text', newThreatIndicatorRule.severity); + getDetails(RISK_SCORE_DETAILS).should('have.text', newThreatIndicatorRule.riskScore); + getDetails(REFERENCE_URLS_DETAILS).should((details) => { + expect(removeExternalLinkText(details.text())).equal(expectedUrls); + }); + getDetails(FALSE_POSITIVES_DETAILS).should('have.text', expectedFalsePositives); + getDetails(MITRE_ATTACK_DETAILS).should((mitre) => { + expect(removeExternalLinkText(mitre.text())).equal(expectedMitre); + }); + getDetails(TAGS_DETAILS).should('have.text', expectedTags); + }); + cy.get(INVESTIGATION_NOTES_TOGGLE).click({ force: true }); + cy.get(ABOUT_INVESTIGATION_NOTES).should('have.text', INVESTIGATION_NOTES_MARKDOWN); + + cy.get(DEFINITION_DETAILS).within(() => { + getDetails(INDEX_PATTERNS_DETAILS).should( + 'have.text', + newThreatIndicatorRule.index!.join('') + ); + getDetails(CUSTOM_QUERY_DETAILS).should('have.text', '*:*'); + getDetails(RULE_TYPE_DETAILS).should('have.text', 'Indicator Match'); + getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', 'None'); + getDetails(INDICATOR_INDEX_PATTERNS).should( + 'have.text', + newThreatIndicatorRule.indicatorIndexPattern.join('') + ); + getDetails(INDICATOR_MAPPING).should( + 'have.text', + `${newThreatIndicatorRule.indicatorMapping} MATCHES ${newThreatIndicatorRule.indicatorIndexField}` + ); + getDetails(INDICATOR_INDEX_QUERY).should('have.text', '*:*'); + }); + + cy.get(SCHEDULE_DETAILS).within(() => { + getDetails(RUNS_EVERY_DETAILS).should( + 'have.text', + `${newThreatIndicatorRule.runsEvery.interval}${newThreatIndicatorRule.runsEvery.type}` + ); + getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should( + 'have.text', + `${newThreatIndicatorRule.lookBack.interval}${newThreatIndicatorRule.lookBack.type}` + ); + }); + + waitForTheRuleToBeExecuted(); + waitForAlertsToPopulate(); + + cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts); + cy.get(ALERT_RULE_NAME).first().should('have.text', newThreatIndicatorRule.name); + cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); + cy.get(ALERT_RULE_METHOD).first().should('have.text', 'threat_match'); + cy.get(ALERT_RULE_SEVERITY) + .first() + .should('have.text', newThreatIndicatorRule.severity.toLowerCase()); + cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', newThreatIndicatorRule.riskScore); + }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts index 2bfd2fbf0054c..ac70a1cae148e 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts @@ -47,7 +47,8 @@ import { openTimeline } from '../../tasks/timelines'; import { OVERVIEW_URL } from '../../urls/navigation'; -describe('Timelines', () => { +// Skipped at the moment as there looks to be in-deterministic bugs with the open timeline dialog. +describe.skip('Timelines', () => { beforeEach(() => { cleanKibana(); }); @@ -89,7 +90,7 @@ describe('Timelines', () => { cy.get(FAVORITE_TIMELINE).should('exist'); cy.get(TIMELINE_TITLE).should('have.text', timeline.title); - cy.get(TIMELINE_DESCRIPTION).should('have.text', timeline.description); + cy.get(TIMELINE_DESCRIPTION).should('have.text', timeline.description); // This is the flake part where it sometimes does not show/load the timelines correctly cy.get(TIMELINE_QUERY).should('have.text', `${timeline.query} `); cy.get(TIMELINE_FILTER(timeline.filter)).should('exist'); cy.get(PIN_EVENT) diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts index 66681e77b7eb9..2a59dd33399c5 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts @@ -38,6 +38,22 @@ export const CUSTOM_QUERY_INPUT = '[data-test-subj="queryInput"]'; export const THREAT_MATCH_QUERY_INPUT = '[data-test-subj="detectionEngineStepDefineThreatRuleQueryBar"] [data-test-subj="queryInput"]'; +export const THREAT_MATCH_AND_BUTTON = '[data-test-subj="andButton"]'; + +export const THREAT_ITEM_ENTRY_DELETE_BUTTON = '[data-test-subj="itemEntryDeleteButton"]'; + +export const THREAT_MATCH_OR_BUTTON = '[data-test-subj="orButton"]'; + +export const THREAT_COMBO_BOX_INPUT = '[data-test-subj="fieldAutocompleteComboBox"]'; + +export const INVALID_MATCH_CONTENT = 'All matches require both a field and threat index field.'; + +export const AT_LEAST_ONE_VALID_MATCH = 'At least one indicator match is required.'; + +export const AT_LEAST_ONE_INDEX_PATTERN = 'A minimum of one index pattern is required.'; + +export const CUSTOM_QUERY_REQUIRED = 'A custom query is required.'; + export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="define-continue"]'; export const DEFINE_EDIT_BUTTON = '[data-test-subj="edit-define-rule"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index 7836960b1a694..5143dc27e7d7a 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -63,13 +63,20 @@ import { EQL_QUERY_PREVIEW_HISTOGRAM, EQL_QUERY_VALIDATION_SPINNER, COMBO_BOX_CLEAR_BTN, - COMBO_BOX_RESULT, MITRE_ATTACK_TACTIC_DROPDOWN, MITRE_ATTACK_TECHNIQUE_DROPDOWN, MITRE_ATTACK_SUBTECHNIQUE_DROPDOWN, MITRE_ATTACK_ADD_TACTIC_BUTTON, MITRE_ATTACK_ADD_SUBTECHNIQUE_BUTTON, MITRE_ATTACK_ADD_TECHNIQUE_BUTTON, + THREAT_COMBO_BOX_INPUT, + THREAT_ITEM_ENTRY_DELETE_BUTTON, + THREAT_MATCH_AND_BUTTON, + INVALID_MATCH_CONTENT, + THREAT_MATCH_OR_BUTTON, + AT_LEAST_ONE_VALID_MATCH, + AT_LEAST_ONE_INDEX_PATTERN, + CUSTOM_QUERY_REQUIRED, } from '../screens/create_new_rule'; import { TOAST_ERROR } from '../screens/shared'; import { SERVER_SIDE_EVENT_COUNT } from '../screens/timeline'; @@ -144,7 +151,7 @@ export const fillAboutRuleAndContinue = ( rule: CustomRule | MachineLearningRule | ThresholdRule | ThreatIndicatorRule ) => { fillAboutRule(rule); - cy.get(ABOUT_CONTINUE_BTN).should('exist').click({ force: true }); + getAboutContinueButton().should('exist').click({ force: true }); }; export const fillAboutRuleWithOverrideAndContinue = (rule: OverrideRule) => { @@ -222,7 +229,7 @@ export const fillAboutRuleWithOverrideAndContinue = (rule: OverrideRule) => { cy.get(COMBO_BOX_INPUT).type(`${rule.timestampOverride}{enter}`); }); - cy.get(ABOUT_CONTINUE_BTN).should('exist').click({ force: true }); + getAboutContinueButton().should('exist').click({ force: true }); }; export const fillDefineCustomRuleWithImportedQueryAndContinue = ( @@ -282,19 +289,132 @@ export const fillDefineEqlRuleAndContinue = (rule: CustomRule) => { cy.get(EQL_QUERY_INPUT).should('not.exist'); }; +/** + * Fills in the indicator match rows for tests by giving it an optional rowNumber, + * a indexField, a indicatorIndexField, and an optional validRows which indicates + * which row is valid or not. + * + * There are special tricks below with Eui combo box: + * cy.get(`button[title="${indexField}"]`) + * .should('be.visible') + * .then(([e]) => e.click()); + * + * To first ensure the button is there before clicking on the button. There are + * race conditions where if the Eui drop down button from the combo box is not + * visible then the click handler is not there either, and when we click on it + * that will cause the item to _not_ be selected. Using a {enter} with the combo + * box also does not select things from EuiCombo boxes either, so I have to click + * the actual contents of the EuiCombo box to select things. + */ +export const fillIndicatorMatchRow = ({ + rowNumber, + indexField, + indicatorIndexField, + validColumns, +}: { + rowNumber?: number; // default is 1 + indexField: string; + indicatorIndexField: string; + validColumns?: 'indexField' | 'indicatorField' | 'both' | 'none'; // default is both are valid entries +}) => { + const computedRowNumber = rowNumber == null ? 1 : rowNumber; + const computedValueRows = validColumns == null ? 'both' : validColumns; + const OFFSET = 2; + cy.get(COMBO_BOX_INPUT) + .eq(computedRowNumber * OFFSET + 1) + .type(indexField); + if (computedValueRows === 'indexField' || computedValueRows === 'both') { + cy.get(`button[title="${indexField}"]`) + .should('be.visible') + .then(([e]) => e.click()); + } + cy.get(COMBO_BOX_INPUT) + .eq(computedRowNumber * OFFSET + 2) + .type(indicatorIndexField); + + if (computedValueRows === 'indicatorField' || computedValueRows === 'both') { + cy.get(`button[title="${indicatorIndexField}"]`) + .should('be.visible') + .then(([e]) => e.click()); + } +}; + +/** + * Fills in both the index pattern and the indicator match index pattern. + * @param indexPattern The index pattern. + * @param indicatorIndex The indicator index pattern. + */ +export const fillIndexAndIndicatorIndexPattern = ( + indexPattern?: string[], + indicatorIndex?: string[] +) => { + getIndexPatternClearButton().click(); + getIndicatorIndex().type(`${indexPattern}{enter}`); + getIndicatorIndicatorIndex().type(`${indicatorIndex}{enter}`); +}; + +/** Returns the indicator index drop down field. Pass in row number, default is 1 */ +export const getIndicatorIndexComboField = (row = 1) => + cy.get(THREAT_COMBO_BOX_INPUT).eq(row * 2 - 2); + +/** Returns the indicator mapping drop down field. Pass in row number, default is 1 */ +export const getIndicatorMappingComboField = (row = 1) => + cy.get(THREAT_COMBO_BOX_INPUT).eq(row * 2 - 1); + +/** Returns the indicator matches DELETE button for the mapping. Pass in row number, default is 1 */ +export const getIndicatorDeleteButton = (row = 1) => + cy.get(THREAT_ITEM_ENTRY_DELETE_BUTTON).eq(row - 1); + +/** Returns the indicator matches AND button for the mapping */ +export const getIndicatorAndButton = () => cy.get(THREAT_MATCH_AND_BUTTON); + +/** Returns the indicator matches OR button for the mapping */ +export const getIndicatorOrButton = () => cy.get(THREAT_MATCH_OR_BUTTON); + +/** Returns the invalid match content. */ +export const getIndicatorInvalidationText = () => cy.contains(INVALID_MATCH_CONTENT); + +/** Returns that at least one valid match is required content */ +export const getIndicatorAtLeastOneInvalidationText = () => cy.contains(AT_LEAST_ONE_VALID_MATCH); + +/** Returns that at least one index pattern is required content */ +export const getIndexPatternInvalidationText = () => cy.contains(AT_LEAST_ONE_INDEX_PATTERN); + +/** Returns the continue button on the step of about */ +export const getAboutContinueButton = () => cy.get(ABOUT_CONTINUE_BTN); + +/** Returns the continue button on the step of define */ +export const getDefineContinueButton = () => cy.get(DEFINE_CONTINUE_BUTTON); + +/** Returns the indicator index pattern */ +export const getIndicatorIndex = () => cy.get(COMBO_BOX_INPUT).eq(0); + +/** Returns the indicator's indicator index */ +export const getIndicatorIndicatorIndex = () => cy.get(COMBO_BOX_INPUT).eq(2); + +/** Returns the index pattern's clear button */ +export const getIndexPatternClearButton = () => cy.get(COMBO_BOX_CLEAR_BTN); + +/** Returns the custom query input */ +export const getCustomQueryInput = () => cy.get(CUSTOM_QUERY_INPUT).eq(0); + +/** Returns the custom query input */ +export const getCustomIndicatorQueryInput = () => cy.get(CUSTOM_QUERY_INPUT).eq(1); + +/** Returns custom query required content */ +export const getCustomQueryInvalidationText = () => cy.contains(CUSTOM_QUERY_REQUIRED); + +/** + * Fills in the define indicator match rules and then presses the continue button + * @param rule The rule to use to fill in everything + */ export const fillDefineIndicatorMatchRuleAndContinue = (rule: ThreatIndicatorRule) => { - const INDEX_PATTERNS = 0; - const INDICATOR_INDEX_PATTERN = 2; - const INDICATOR_MAPPING = 3; - const INDICATOR_INDEX_FIELD = 4; - - cy.get(COMBO_BOX_CLEAR_BTN).click(); - cy.get(COMBO_BOX_INPUT).eq(INDEX_PATTERNS).type(`${rule.index}{enter}`); - cy.get(COMBO_BOX_INPUT).eq(INDICATOR_INDEX_PATTERN).type(`${rule.indicatorIndexPattern}{enter}`); - cy.get(COMBO_BOX_INPUT).eq(INDICATOR_MAPPING).type(`${rule.indicatorMapping}{enter}`); - cy.get(COMBO_BOX_RESULT).first().click(); - cy.get(COMBO_BOX_INPUT).eq(INDICATOR_INDEX_FIELD).type(`${rule.indicatorIndexField}{enter}`); - cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true }); + fillIndexAndIndicatorIndexPattern(rule.index, rule.indicatorIndexPattern); + fillIndicatorMatchRow({ + indexField: rule.indicatorMapping, + indicatorIndexField: rule.indicatorIndexField, + }); + getDefineContinueButton().should('exist').click({ force: true }); cy.get(CUSTOM_QUERY_INPUT).should('not.exist'); }; @@ -304,7 +424,7 @@ export const fillDefineMachineLearningRuleAndContinue = (rule: MachineLearningRu cy.get(ANOMALY_THRESHOLD_INPUT).type(`{selectall}${machineLearningRule.anomalyScoreThreshold}`, { force: true, }); - cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true }); + getDefineContinueButton().should('exist').click({ force: true }); cy.get(MACHINE_LEARNING_DROPDOWN).should('not.exist'); }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/timelines.ts b/x-pack/plugins/security_solution/cypress/tasks/timelines.ts index a04ecb1f9ccaa..c2b5790b1ae12 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timelines.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timelines.ts @@ -19,7 +19,10 @@ export const exportTimeline = (timelineId: string) => { }; export const openTimeline = (id: string) => { - cy.get(TIMELINE(id), { timeout: 500 }).click(); + // This temporary wait here is to reduce flakeyness until we integrate cypress-pipe. Then please let us use cypress pipe. + // Ref: https://www.cypress.io/blog/2019/01/22/when-can-the-test-click/ + // Ref: https://github.com/NicholasBoll/cypress-pipe#readme + cy.get(TIMELINE(id)).should('be.visible').wait(1500).click(); }; export const waitForTimelinesPanelToBeLoaded = () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index 71fd74570c16a..009053067064a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -20,6 +20,7 @@ import { useDeleteCases } from '../../containers/use_delete_cases'; import { useGetCases } from '../../containers/use_get_cases'; import { useGetCasesStatus } from '../../containers/use_get_cases_status'; import { useUpdateCases } from '../../containers/use_bulk_update_case'; +import { useGetActionLicense } from '../../containers/use_get_action_license'; import { getCasesColumns } from './columns'; import { AllCases } from '.'; @@ -27,12 +28,14 @@ jest.mock('../../containers/use_bulk_update_case'); jest.mock('../../containers/use_delete_cases'); jest.mock('../../containers/use_get_cases'); jest.mock('../../containers/use_get_cases_status'); +jest.mock('../../containers/use_get_action_license'); const useKibanaMock = useKibana as jest.Mocked; const useDeleteCasesMock = useDeleteCases as jest.Mock; const useGetCasesMock = useGetCases as jest.Mock; const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; const useUpdateCasesMock = useUpdateCases as jest.Mock; +const useGetActionLicenseMock = useGetActionLicense as jest.Mock; jest.mock('../../../common/components/link_to'); @@ -86,6 +89,12 @@ describe('AllCases', () => { updateBulkStatus, }; + const defaultActionLicense = { + actionLicense: null, + isLoading: false, + isError: false, + }; + let navigateToApp: jest.Mock; beforeEach(() => { @@ -96,6 +105,7 @@ describe('AllCases', () => { useGetCasesMock.mockReturnValue(defaultGetCases); useDeleteCasesMock.mockReturnValue(defaultDeleteCases); useGetCasesStatusMock.mockReturnValue(defaultCasesStatus); + useGetActionLicenseMock.mockReturnValue(defaultActionLicense); moment.tz.setDefault('UTC'); }); @@ -398,6 +408,7 @@ describe('AllCases', () => { expect(dispatchResetIsDeleted).toBeCalled(); }); }); + it('isUpdated is true, refetch', async () => { useUpdateCasesMock.mockReturnValue({ ...defaultUpdateCases, @@ -627,4 +638,56 @@ describe('AllCases', () => { ); }); }); + + it('should not allow the user to enter configuration page with basic license', async () => { + useGetActionLicenseMock.mockReturnValue({ + ...defaultActionLicense, + actionLicense: { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: false, + }, + }); + + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect( + wrapper.find('[data-test-subj="configure-case-button"]').first().prop('isDisabled') + ).toBeTruthy(); + }); + }); + + it('should allow the user to enter configuration page with gold license and above', async () => { + useGetActionLicenseMock.mockReturnValue({ + ...defaultActionLicense, + actionLicense: { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + }); + + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect( + wrapper.find('[data-test-subj="configure-case-button"]').first().prop('isDisabled') + ).toBeFalsy(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx index b1b5f2b087eee..93890656b4a7f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ConnectorTypes } from '../../../../../../case/common/api'; import { ActionConnector } from '../../../containers/configure/types'; import { UseConnectorsResponse } from '../../../containers/configure/use_connectors'; -import { connectorsMock } from '../../../containers/configure/mock'; import { ReturnUseCaseConfigure } from '../../../containers/configure/use_configure'; -import { ConnectorTypes } from '../../../../../../case/common/api'; +import { UseActionTypesResponse } from '../../../containers/configure/use_action_types'; +import { connectorsMock, actionTypesMock } from '../../../containers/configure/mock'; export { mappings } from '../../../containers/configure/mock'; export const connectors: ActionConnector[] = connectorsMock; @@ -51,3 +52,9 @@ export const useConnectorsResponse: UseConnectorsResponse = { connectors, refetchConnectors: jest.fn(), }; + +export const useActionTypesResponse: UseActionTypesResponse = { + loading: false, + actionTypes: actionTypesMock, + refetchActionTypes: jest.fn(), +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.tsx index 44767471dd9e7..9f3dcd168ba5f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.tsx @@ -38,6 +38,7 @@ const ConfigureCaseButtonComponent: React.FC = ({ }, [history, urlSearch] ); + const configureCaseButton = useMemo( () => ( = ({ ), [label, isDisabled, formatUrl, goToCaseConfigure] ); + return showToolTip ? ( ; const useConnectorsMock = useConnectors as jest.Mock; const useCaseConfigureMock = useCaseConfigure as jest.Mock; const useGetUrlSearchMock = useGetUrlSearch as jest.Mock; +const useActionTypesMock = useActionTypes as jest.Mock; describe('ConfigureCases', () => { beforeEach(() => { @@ -83,6 +92,8 @@ describe('ConfigureCases', () => { /> )), } as unknown) as TriggersAndActionsUIPublicPluginStart; + + useActionTypesMock.mockImplementation(() => useActionTypesResponse); }); describe('rendering', () => { @@ -265,10 +276,12 @@ describe('ConfigureCases', () => { closureType: 'close-by-user', }, })); + useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, loading: true, })); + useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(, { wrappingComponent: TestProviders }); }); @@ -294,6 +307,18 @@ describe('ConfigureCases', () => { .prop('disabled') ).toBe(true); }); + + test('it shows isLoading when loading action types', () => { + useConnectorsMock.mockImplementation(() => ({ + ...useConnectorsResponse, + loading: false, + })); + + useActionTypesMock.mockImplementation(() => ({ ...useActionTypesResponse, loading: true })); + + wrapper = mount(, { wrappingComponent: TestProviders }); + expect(wrapper.find(Connectors).prop('isLoading')).toBe(true); + }); }); describe('saving configuration', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx index d34dc168ba7a2..bc56e404c891d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx @@ -9,16 +9,16 @@ import styled, { css } from 'styled-components'; import { EuiCallOut } from '@elastic/eui'; +import { SUPPORTED_CONNECTORS } from '../../../../../case/common/constants'; import { useKibana } from '../../../common/lib/kibana'; import { useConnectors } from '../../containers/configure/use_connectors'; +import { useActionTypes } from '../../containers/configure/use_action_types'; import { useCaseConfigure } from '../../containers/configure/use_configure'; -import { ActionType } from '../../../../../triggers_actions_ui/public'; import { ClosureType } from '../../containers/configure/types'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ActionConnectorTableItem } from '../../../../../triggers_actions_ui/public/types'; -import { connectorsConfiguration } from '../connectors'; import { SectionWrapper } from '../wrappers'; import { Connectors } from './connectors'; @@ -49,8 +49,6 @@ const FormWrapper = styled.div` `} `; -const actionTypes: ActionType[] = Object.values(connectorsConfiguration); - interface ConfigureCasesComponentProps { userCanCrud: boolean; } @@ -78,12 +76,20 @@ const ConfigureCasesComponent: React.FC = ({ userC } = useCaseConfigure(); const { loading: isLoadingConnectors, connectors, refetchConnectors } = useConnectors(); + const { loading: isLoadingActionTypes, actionTypes, refetchActionTypes } = useActionTypes(); + const supportedActionTypes = useMemo( + () => actionTypes.filter((actionType) => SUPPORTED_CONNECTORS.includes(actionType.id)), + [actionTypes] + ); const onConnectorUpdate = useCallback(async () => { refetchConnectors(); + refetchActionTypes(); refetchCaseConfigure(); - }, [refetchCaseConfigure, refetchConnectors]); - const isLoadingAny = isLoadingConnectors || persistLoading || loadingCaseConfigure; + }, [refetchActionTypes, refetchCaseConfigure, refetchConnectors]); + + const isLoadingAny = + isLoadingConnectors || persistLoading || loadingCaseConfigure || isLoadingActionTypes; const updateConnectorDisabled = isLoadingAny || !connectorIsValid || connector.id === 'none'; const onClickUpdateConnector = useCallback(() => { setEditFlyoutVisibility(true); @@ -154,11 +160,11 @@ const ConfigureCasesComponent: React.FC = ({ userC triggersActionsUi.getAddConnectorFlyout({ consumer: 'case', onClose: onCloseAddFlyout, - actionTypes, + actionTypes: supportedActionTypes, reloadConnectors: onConnectorUpdate, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [supportedActionTypes] ); const ConnectorEditFlyout = useMemo( diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx index 43f2a2a6e12f1..396ce0725eb3a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx @@ -17,11 +17,16 @@ export const getLicenseError = () => ({ title: i18n.PUSH_DISABLE_BY_LICENSE_TITLE, description: ( + appropriateLicense: ( + + {i18n.LINK_APPROPRIATE_LICENSE} + + ), + cloud: ( + {i18n.LINK_CLOUD_DEPLOYMENT} ), diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/translations.ts b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/translations.ts index f4539b8019d43..16f1b8965bb0b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/translations.ts @@ -69,7 +69,7 @@ export const PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE = i18n.translate( export const PUSH_DISABLE_BY_LICENSE_TITLE = i18n.translate( 'xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseTitle', { - defaultMessage: 'Upgrade to Elastic Platinum', + defaultMessage: 'Upgrade to an appropriate license', } ); @@ -80,6 +80,13 @@ export const LINK_CLOUD_DEPLOYMENT = i18n.translate( } ); +export const LINK_APPROPRIATE_LICENSE = i18n.translate( + 'xpack.securitySolution.case.caseView.appropiateLicense', + { + defaultMessage: 'appropriate license', + } +); + export const LINK_CONNECTOR_CONFIGURE = i18n.translate( 'xpack.securitySolution.case.caseView.connectorConfigureLink', { diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts index 257cb171a4a9a..ed2f77657fb5e 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts @@ -8,11 +8,12 @@ import { CasesConfigurePatch, CasesConfigureRequest, ActionConnector, + ActionTypeConnector, } from '../../../../../../case/common/api'; import { ApiProps } from '../../types'; import { CaseConfigure } from '../types'; -import { connectorsMock, caseConfigurationCamelCaseResponseMock } from '../mock'; +import { connectorsMock, caseConfigurationCamelCaseResponseMock, actionTypesMock } from '../mock'; export const fetchConnectors = async ({ signal }: ApiProps): Promise => Promise.resolve(connectorsMock); @@ -29,3 +30,6 @@ export const patchCaseConfigure = async ( caseConfiguration: CasesConfigurePatch, signal: AbortSignal ): Promise => Promise.resolve(caseConfigurationCamelCaseResponseMock); + +export const fetchActionTypes = async ({ signal }: ApiProps): Promise => + Promise.resolve(actionTypesMock); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts index f9115963c745d..70576482fbe89 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts @@ -5,9 +5,16 @@ */ import { KibanaServices } from '../../../common/lib/kibana'; -import { fetchConnectors, getCaseConfigure, postCaseConfigure, patchCaseConfigure } from './api'; +import { + fetchConnectors, + getCaseConfigure, + postCaseConfigure, + patchCaseConfigure, + fetchActionTypes, +} from './api'; import { connectorsMock, + actionTypesMock, caseConfigurationMock, caseConfigurationResposeMock, caseConfigurationCamelCaseResponseMock, @@ -123,4 +130,24 @@ describe('Case Configuration API', () => { expect(resp).toEqual(caseConfigurationCamelCaseResponseMock); }); }); + + describe('fetch actionTypes', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(actionTypesMock); + }); + + test('check url, method, signal', async () => { + await fetchActionTypes({ signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/actions/list_action_types', { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await fetchActionTypes({ signal: abortCtrl.signal }); + expect(resp).toEqual(actionTypesMock); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts index 8652e48fd834d..2b2bd1a782f75 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts @@ -7,6 +7,7 @@ import { isEmpty } from 'lodash/fp'; import { ActionConnector, + ActionTypeConnector, CasesConfigurePatch, CasesConfigureResponse, CasesConfigureRequest, @@ -16,6 +17,7 @@ import { KibanaServices } from '../../../common/lib/kibana'; import { CASE_CONFIGURE_CONNECTORS_URL, CASE_CONFIGURE_URL, + ACTION_TYPES_URL, } from '../../../../../case/common/constants'; import { ApiProps } from '../types'; @@ -89,3 +91,12 @@ export const patchCaseConfigure = async ( decodeCaseConfigureResponse(response) ); }; + +export const fetchActionTypes = async ({ signal }: ApiProps): Promise => { + const response = await KibanaServices.get().http.fetch(ACTION_TYPES_URL, { + method: 'GET', + signal, + }); + + return response; +}; diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts index fabd1187698a7..79aaaab61324e 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts @@ -6,6 +6,7 @@ import { ActionConnector, + ActionTypeConnector, CasesConfigureResponse, CasesConfigureRequest, ConnectorTypes, @@ -29,6 +30,7 @@ export const mappings: CaseConnectorMapping[] = [ actionType: 'append', }, ]; + export const connectorsMock: ActionConnector[] = [ { id: 'servicenow-1', @@ -60,6 +62,49 @@ export const connectorsMock: ActionConnector[] = [ }, ]; +export const actionTypesMock: ActionTypeConnector[] = [ + { + id: '.email', + name: 'Email', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.index', + name: 'Index', + minimumLicenseRequired: 'basic', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.servicenow', + name: 'ServiceNow', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.resilient', + name: 'IBM Resilient', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, +]; + export const caseConfigurationResposeMock: CasesConfigureResponse = { created_at: '2020-04-06T13:03:18.657Z', created_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts index 41acb91f2ae96..ff2441d361c2c 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts @@ -7,6 +7,7 @@ import { ElasticUser } from '../types'; import { ActionConnector, + ActionTypeConnector, ActionType, CaseConnector, CaseField, @@ -15,7 +16,15 @@ import { ThirdPartyField, } from '../../../../../case/common/api'; -export { ActionConnector, ActionType, CaseConnector, CaseField, ClosureType, ThirdPartyField }; +export { + ActionConnector, + ActionTypeConnector, + ActionType, + CaseConnector, + CaseField, + ClosureType, + ThirdPartyField, +}; export interface CaseConnectorMapping { actionType: ActionType; diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.test.tsx new file mode 100644 index 0000000000000..b2213fb8fc8c4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.test.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useActionTypes, UseActionTypesResponse } from './use_action_types'; +import { actionTypesMock } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useActionTypes', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useActionTypes() + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + loading: true, + actionTypes: [], + refetchActionTypes: result.current.refetchActionTypes, + }); + }); + }); + + test('fetch action types', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useActionTypes() + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + loading: false, + actionTypes: actionTypesMock, + refetchActionTypes: result.current.refetchActionTypes, + }); + }); + }); + + test('refetch actionTypes', async () => { + const spyOnfetchActionTypes = jest.spyOn(api, 'fetchActionTypes'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useActionTypes() + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + result.current.refetchActionTypes(); + expect(spyOnfetchActionTypes).toHaveBeenCalledTimes(2); + }); + }); + + test('set isLoading to true when refetching actionTypes', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useActionTypes() + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + result.current.refetchActionTypes(); + + expect(result.current.loading).toBe(true); + }); + }); + + test('unhappy path', async () => { + const spyOnfetchActionTypes = jest.spyOn(api, 'fetchActionTypes'); + spyOnfetchActionTypes.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useActionTypes() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + loading: false, + actionTypes: [], + refetchActionTypes: result.current.refetchActionTypes, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx new file mode 100644 index 0000000000000..980db8ed61f8f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; + +import { useStateToaster, errorToToaster } from '../../../common/components/toasters'; +import * as i18n from '../translations'; +import { fetchActionTypes } from './api'; +import { ActionTypeConnector } from './types'; + +export interface UseActionTypesResponse { + loading: boolean; + actionTypes: ActionTypeConnector[]; + refetchActionTypes: () => void; +} + +export const useActionTypes = (): UseActionTypesResponse => { + const [, dispatchToaster] = useStateToaster(); + const [loading, setLoading] = useState(true); + const [actionTypes, setActionTypes] = useState([]); + const didCancel = useRef(false); + const abortCtrl = useRef(new AbortController()); + const queryFirstTime = useRef(true); + + const refetchActionTypes = useCallback(async () => { + try { + setLoading(true); + didCancel.current = false; + abortCtrl.current.abort(); + abortCtrl.current = new AbortController(); + + const res = await fetchActionTypes({ signal: abortCtrl.current.signal }); + + if (!didCancel.current) { + setLoading(false); + setActionTypes(res); + } + } catch (error) { + if (!didCancel.current) { + setLoading(false); + setActionTypes([]); + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } + } + }, [dispatchToaster]); + + useEffect(() => { + if (queryFirstTime.current) { + refetchActionTypes(); + queryFirstTime.current = false; + } + + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + queryFirstTime.current = true; + }; + }, [refetchActionTypes]); + + return { + loading, + actionTypes, + refetchActionTypes, + }; +}; diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts index fd24a8451fcbe..3fb962df232bc 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -199,6 +199,13 @@ export const actionLicenses: ActionLicense[] = [ enabledInConfig: true, enabledInLicense: true, }, + { + id: '.jira', + name: 'Jira', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, ]; // Snake case for mock api responses diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.test.tsx index 23c9ff5e49586..3e501a5276d5b 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.test.tsx @@ -51,7 +51,7 @@ describe('useGetActionLicense', () => { expect(result.current).toEqual({ isLoading: false, isError: false, - actionLicense: actionLicenses[0], + actionLicense: actionLicenses[1], }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx index e289a1973cf6e..8ce5c4aeef4b6 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx @@ -23,6 +23,8 @@ export const initialData: ActionLicenseState = { isError: false, }; +const MINIMUM_LICENSE_REQUIRED_CONNECTOR = '.jira'; + export const useGetActionLicense = (): ActionLicenseState => { const [actionLicenseState, setActionLicensesState] = useState(initialData); @@ -40,7 +42,8 @@ export const useGetActionLicense = (): ActionLicenseState => { const response = await getActionLicense(abortCtrl.signal); if (!didCancel) { setActionLicensesState({ - actionLicense: response.find((l) => l.id === '.servicenow') ?? null, + actionLicense: + response.find((l) => l.id === MINIMUM_LICENSE_REQUIRED_CONNECTOR) ?? null, isLoading: false, isError: false, }); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx index 36033c358766d..ce6ca7ebc22dd 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx @@ -22,6 +22,7 @@ describe('EntryItem', () => { const wrapper = mount( { const wrapper = mount( { expect(mockOnChange).toHaveBeenCalledWith( { + id: '123', field: 'machine.os', type: 'mapping', value: 'ip', @@ -97,6 +100,7 @@ describe('EntryItem', () => { const wrapper = mount( { onChange: (a: EuiComboBoxOptionOption[]) => void; }).onChange([{ label: 'is not' }]); - expect(mockOnChange).toHaveBeenCalledWith({ field: 'ip', type: 'mapping', value: '' }, 0); + expect(mockOnChange).toHaveBeenCalledWith( + { id: '123', field: 'ip', type: 'mapping', value: '' }, + 0 + ); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx index c99e63ff4eda0..51b724bff2e5d 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx @@ -75,7 +75,11 @@ export const EntryItem: React.FC = ({
); } else { - return comboBox; + return ( + + {comboBox} + + ); } }, [handleFieldChange, indexPattern, entry, showLabel]); @@ -101,7 +105,11 @@ export const EntryItem: React.FC = ({ ); } else { - return comboBox; + return ( + + {comboBox} + + ); } }, [handleThreatFieldChange, threatIndexPatterns, entry, showLabel]); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx index b4f97808b54c4..b3a74c7697715 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx @@ -21,6 +21,10 @@ import { } from './helpers'; import { ThreatMapEntry } from '../../../../common/detection_engine/schemas/types'; +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + const getMockIndexPattern = (): IndexPattern => ({ id: '1234', @@ -29,6 +33,7 @@ const getMockIndexPattern = (): IndexPattern => } as IndexPattern); const getMockEntry = (): FormattedEntry => ({ + id: '123', field: getField('ip'), value: getField('ip'), type: 'mapping', @@ -42,6 +47,7 @@ describe('Helpers', () => { afterEach(() => { moment.tz.setDefault('Browser'); + jest.clearAllMocks(); }); describe('#getFormattedEntry', () => { @@ -70,6 +76,7 @@ describe('Helpers', () => { const output = getFormattedEntry(payloadIndexPattern, payloadIndexPattern, payloadItem, 0); const expected: FormattedEntry = { entryIndex: 0, + id: '123', field: { name: 'machine.os.raw.text', type: 'string', @@ -94,6 +101,7 @@ describe('Helpers', () => { const output = getFormattedEntries(payloadIndexPattern, payloadIndexPattern, payloadItems); const expected: FormattedEntry[] = [ { + id: '123', entryIndex: 0, field: undefined, value: undefined, @@ -109,6 +117,7 @@ describe('Helpers', () => { const output = getFormattedEntries(payloadIndexPattern, payloadIndexPattern, payloadItems); const expected: FormattedEntry[] = [ { + id: '123', entryIndex: 0, field: { name: 'machine.os', @@ -134,6 +143,7 @@ describe('Helpers', () => { const output = getFormattedEntries(payloadIndexPattern, threatIndexPattern, payloadItems); const expected: FormattedEntry[] = [ { + id: '123', entryIndex: 0, field: { name: 'machine.os', @@ -170,6 +180,7 @@ describe('Helpers', () => { const output = getFormattedEntries(payloadIndexPattern, payloadIndexPattern, payloadItems); const expected: FormattedEntry[] = [ { + id: '123', field: { name: 'machine.os', type: 'string', @@ -194,6 +205,7 @@ describe('Helpers', () => { entryIndex: 0, }, { + id: '123', field: { name: 'ip', type: 'ip', @@ -249,9 +261,10 @@ describe('Helpers', () => { const payloadItem = getMockEntry(); const payloadIFieldType = getField('ip'); const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: Entry; index: number } = { + const expected: { updatedEntry: Entry & { id: string }; index: number } = { index: 0, updatedEntry: { + id: '123', field: 'ip', type: 'mapping', value: 'ip', diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx index 349dae76301d4..90a996c06e492 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import uuid from 'uuid'; import { ThreatMap, threatMap, @@ -12,6 +13,7 @@ import { import { IndexPattern, IFieldType } from '../../../../../../../src/plugins/data/common'; import { Entry, FormattedEntry, ThreatMapEntries, EmptyEntry } from './types'; +import { addIdToItem } from '../../utils/add_remove_id_to_item'; /** * Formats the entry into one that is easily usable for the UI. @@ -24,7 +26,8 @@ export const getFormattedEntry = ( indexPattern: IndexPattern, threatIndexPatterns: IndexPattern, item: Entry, - itemIndex: number + itemIndex: number, + uuidGen: () => string = uuid.v4 ): FormattedEntry => { const { fields } = indexPattern; const { fields: threatFields } = threatIndexPatterns; @@ -34,7 +37,9 @@ export const getFormattedEntry = ( const [threatFoundField] = threatFields.filter( ({ name }) => threatField != null && threatField === name ); + const maybeId: typeof item & { id?: string } = item; return { + id: maybeId.id ?? uuidGen(), field: foundField, type: 'mapping', value: threatFoundField, @@ -90,10 +95,11 @@ export const getEntryOnFieldChange = ( const { entryIndex } = item; return { updatedEntry: { + id: item.id, field: newField != null ? newField.name : '', type: 'mapping', value: item.value != null ? item.value.name : '', - }, + } as Entry, // Cast to Entry since id is only used as a react key prop and can be ignored elsewhere index: entryIndex, }; }; @@ -112,30 +118,33 @@ export const getEntryOnThreatFieldChange = ( const { entryIndex } = item; return { updatedEntry: { + id: item.id, field: item.field != null ? item.field.name : '', type: 'mapping', value: newField != null ? newField.name : '', - }, + } as Entry, // Cast to Entry since id is only used as a react key prop and can be ignored elsewhere index: entryIndex, }; }; -export const getDefaultEmptyEntry = (): EmptyEntry => ({ - field: '', - type: 'mapping', - value: '', -}); +export const getDefaultEmptyEntry = (): EmptyEntry => { + return addIdToItem({ + field: '', + type: 'mapping', + value: '', + }); +}; export const getNewItem = (): ThreatMap => { - return { + return addIdToItem({ entries: [ - { + addIdToItem({ field: '', type: 'mapping', value: '', - }, + }), ], - }; + }); }; export const filterItems = (items: ThreatMapEntries[]): ThreatMapping => { diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx index d3936e10bd877..8aa4af21b03cc 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx @@ -158,43 +158,45 @@ export const ThreatMatchComponent = ({ }, []); return ( - {entries.map((entryListItem, index) => ( - - - {index !== 0 && - (andLogicIncluded ? ( - - - - - - - - - - - ) : ( - - - - ))} - - - - - - ))} + {entries.map((entryListItem, index) => { + const key = (entryListItem as typeof entryListItem & { id?: string }).id ?? `${index}`; + return ( + + + {index !== 0 && + (andLogicIncluded ? ( + + + + + + + + + + + ) : ( + + + + ))} + + + + + + ); + })} diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx index 90492bc46e2b0..66af24025656e 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx @@ -68,7 +68,6 @@ describe('ListItemComponent', () => { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { const wrapper = mount( { const wrapper = mount( { const wrapper = mount( { const wrapper = mount( { const wrapper = mount( ( ({ listItem, - listId, listItemIndex, indexPattern, threatIndexPatterns, @@ -88,7 +86,7 @@ export const ListItemComponent = React.memo( {entries.map((item, index) => ( - + ({ + v4: jest.fn().mockReturnValue('123'), +})); + const initialState: State = { andLogicIncluded: false, entries: [], @@ -22,6 +26,10 @@ const getEntry = (): ThreatMapEntry => ({ }); describe('reducer', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + describe('#setEntries', () => { test('should return "andLogicIncluded" ', () => { const update = reducer()(initialState, { diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts b/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts index 0cbd885db2d54..f3af5faaed25c 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts @@ -7,6 +7,7 @@ import { ThreatMap, ThreatMapEntry } from '../../../../common/detection_engine/s import { IFieldType } from '../../../../../../../src/plugins/data/common'; export interface FormattedEntry { + id: string; field: IFieldType | undefined; type: 'mapping'; value: IFieldType | undefined; diff --git a/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts b/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts new file mode 100644 index 0000000000000..fa067a53f2573 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { addIdToItem, removeIdFromItem } from './add_remove_id_to_item'; + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + +describe('add_remove_id_to_item', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('addIdToItem', () => { + test('it adds an id to an empty item', () => { + expect(addIdToItem({})).toEqual({ id: '123' }); + }); + + test('it adds a complex object', () => { + expect( + addIdToItem({ + field: '', + type: 'mapping', + value: '', + }) + ).toEqual({ + id: '123', + field: '', + type: 'mapping', + value: '', + }); + }); + + test('it adds an id to an existing item', () => { + expect(addIdToItem({ test: '456' })).toEqual({ id: '123', test: '456' }); + }); + + test('it does not change the id if it already exists', () => { + expect(addIdToItem({ id: '456' })).toEqual({ id: '456' }); + }); + + test('it returns the same reference if it has an id already', () => { + const obj = { id: '456' }; + expect(addIdToItem(obj)).toBe(obj); + }); + + test('it returns a new reference if it adds an id to an item', () => { + const obj = { test: '456' }; + expect(addIdToItem(obj)).not.toBe(obj); + }); + }); + + describe('removeIdFromItem', () => { + test('it removes an id from an item', () => { + expect(removeIdFromItem({ id: '456' })).toEqual({}); + }); + + test('it returns a new reference if it removes an id from an item', () => { + const obj = { id: '123', test: '456' }; + expect(removeIdFromItem(obj)).not.toBe(obj); + }); + + test('it does not effect an item without an id', () => { + expect(removeIdFromItem({ test: '456' })).toEqual({ test: '456' }); + }); + + test('it returns the same reference if it does not have an id already', () => { + const obj = { test: '456' }; + expect(removeIdFromItem(obj)).toBe(obj); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts b/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts new file mode 100644 index 0000000000000..a74cf8680fa48 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; + +/** + * This is useful for when you have arrays without an ID and need to add one for + * ReactJS keys. I break the types slightly by introducing an id to an arbitrary item + * but then cast it back to the regular type T. + * Usage of this could be considered tech debt as I am adding an ID when the backend + * could be doing the same thing but it depends on how you want to model your data and + * if you view modeling your data with id's to please ReactJS a good or bad thing. + * @param item The item to add an id to. + */ +type NotArray = T extends unknown[] ? never : T; +export const addIdToItem = (item: NotArray): T => { + const maybeId: typeof item & { id?: string } = item; + if (maybeId.id != null) { + return item; + } else { + return { ...item, id: uuid.v4() }; + } +}; + +/** + * This is to reverse the id you added to your arrays for ReactJS keys. + * @param item The item to remove the id from. + */ +export const removeIdFromItem = ( + item: NotArray +): + | T + | Pick< + T & { + id?: string | undefined; + }, + Exclude + > => { + const maybeId: typeof item & { id?: string } = item; + if (maybeId.id != null) { + const { id, ...noId } = maybeId; + return noId; + } else { + return item; + } +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_privilege_user.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_privilege_user.tsx index b72dd3b2f84dd..191c3955caa9b 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_privilege_user.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_privilege_user.tsx @@ -50,7 +50,7 @@ export const usePrivilegeUser = (): ReturnPrivilegeUser => { const abortCtrl = new AbortController(); setLoading(true); - async function fetchData() { + const fetchData = async () => { try { const privilege = await getUserPrivilege({ signal: abortCtrl.signal, @@ -89,15 +89,14 @@ export const usePrivilegeUser = (): ReturnPrivilegeUser => { if (isSubscribed) { setLoading(false); } - } + }; fetchData(); return () => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [dispatchToaster]); return { loading, ...privilegeUser }; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx index 3bef1d8edd048..9022e3a32163c 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx @@ -46,7 +46,7 @@ export const useQueryAlerts = ( let isSubscribed = true; const abortCtrl = new AbortController(); - async function fetchData() { + const fetchData = async () => { try { setLoading(true); const alertResponse = await fetchQueryAlerts({ @@ -77,7 +77,7 @@ export const useQueryAlerts = ( if (isSubscribed) { setLoading(false); } - } + }; fetchData(); return () => { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx index 5ebdb38b8dd5c..bfdc1d1ceee21 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx @@ -106,8 +106,7 @@ export const useSignalIndex = (): ReturnSignalIndex => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [dispatchToaster]); return { loading, ...signalIndex }; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts new file mode 100644 index 0000000000000..7821bb23a7ca3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { flow } from 'fp-ts/lib/function'; +import { addIdToItem, removeIdFromItem } from '../../../../common/utils/add_remove_id_to_item'; +import { + CreateRulesSchema, + UpdateRulesSchema, +} from '../../../../../common/detection_engine/schemas/request'; +import { Rule } from './types'; + +// These are a collection of transforms that are UI specific and useful for UI concerns +// that are inserted between the API and the actual user interface. In some ways these +// might be viewed as technical debt or to compensate for the differences and preferences +// of how ReactJS might prefer data vs. how we want to model data. Each function should have +// a description giving context around the transform. + +/** + * Transforms the output of rules to compensate for technical debt or UI concerns such as + * ReactJS preferences for having ids within arrays if the data is not modeled that way. + * + * If you add a new transform of the output called "myNewTransform" do it + * in the form of: + * flow(removeIdFromThreatMatchArray, myNewTransform)(rule) + * + * @param rule The rule to transform the output of + * @returns The rule transformed from the output + */ +export const transformOutput = ( + rule: CreateRulesSchema | UpdateRulesSchema +): CreateRulesSchema | UpdateRulesSchema => flow(removeIdFromThreatMatchArray)(rule); + +/** + * Transforms the output of rules to compensate for technical debt or UI concerns such as + * ReactJS preferences for having ids within arrays if the data is not modeled that way. + * + * If you add a new transform of the input called "myNewTransform" do it + * in the form of: + * flow(addIdToThreatMatchArray, myNewTransform)(rule) + * + * @param rule The rule to transform the output of + * @returns The rule transformed from the output + */ +export const transformInput = (rule: Rule): Rule => flow(addIdToThreatMatchArray)(rule); + +/** + * This adds an id to the incoming threat match arrays as ReactJS prefers to have + * an id added to them for use as a stable id. Later if we decide to change the data + * model to have id's within the array then this code should be removed. If not, then + * this code should stay as an adapter for ReactJS. + * + * This does break the type system slightly as we are lying a bit to the type system as we return + * the same rule as we have previously but are augmenting the arrays with an id which TypeScript + * doesn't mind us doing here. However, downstream you will notice that you have an id when the type + * does not indicate it. In that case just cast this temporarily if you're using the id. If you're not, + * you can ignore the id and just use the normal TypeScript with ReactJS. + * + * @param rule The rule to add an id to the threat matches. + * @returns rule The rule but with id added to the threat array and entries + */ +export const addIdToThreatMatchArray = (rule: Rule): Rule => { + if (rule.type === 'threat_match' && rule.threat_mapping != null) { + const threatMapWithId = rule.threat_mapping.map((mapping) => { + const newEntries = mapping.entries.map((entry) => addIdToItem(entry)); + return addIdToItem({ entries: newEntries }); + }); + return { ...rule, threat_mapping: threatMapWithId }; + } else { + return rule; + } +}; + +/** + * This removes an id from the threat match arrays as ReactJS prefers to have + * an id added to them for use as a stable id. Later if we decide to change the data + * model to have id's within the array then this code should be removed. If not, then + * this code should stay as an adapter for ReactJS. + * + * @param rule The rule to remove an id from the threat matches. + * @returns rule The rule but with id removed from the threat array and entries + */ +export const removeIdFromThreatMatchArray = ( + rule: CreateRulesSchema | UpdateRulesSchema +): CreateRulesSchema | UpdateRulesSchema => { + if (rule.type === 'threat_match' && rule.threat_mapping != null) { + const threatMapWithoutId = rule.threat_mapping.map((mapping) => { + const newEntries = mapping.entries.map((entry) => removeIdFromItem(entry)); + const newMapping = removeIdFromItem(mapping); + return { ...newMapping, entries: newEntries }; + }); + return { ...rule, threat_mapping: threatMapWithoutId }; + } else { + return rule; + } +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx index 2bbd27994fc77..fe8e0fd8ceb97 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx @@ -11,6 +11,7 @@ import { CreateRulesSchema } from '../../../../../common/detection_engine/schema import { createRule } from './api'; import * as i18n from './translations'; +import { transformOutput } from './transforms'; interface CreateRuleReturn { isLoading: boolean; @@ -29,11 +30,11 @@ export const useCreateRule = (): ReturnCreateRule => { let isSubscribed = true; const abortCtrl = new AbortController(); setIsSaved(false); - async function saveRule() { + const saveRule = async () => { if (rule != null) { try { setIsLoading(true); - await createRule({ rule, signal: abortCtrl.signal }); + await createRule({ rule: transformOutput(rule), signal: abortCtrl.signal }); if (isSubscribed) { setIsSaved(true); } @@ -46,15 +47,14 @@ export const useCreateRule = (): ReturnCreateRule => { setIsLoading(false); } } - } + }; saveRule(); return () => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rule]); + }, [rule, dispatchToaster]); return [{ isLoading, isSaved }, setRule]; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx index d83d4e0caa977..bdbe13af40151 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx @@ -262,8 +262,14 @@ export const usePrePackagedRules = ({ isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [canUserCRUD, hasIndexWrite, isAuthenticated, hasEncryptionKey, isSignalIndexExists]); + }, [ + canUserCRUD, + hasIndexWrite, + isAuthenticated, + hasEncryptionKey, + isSignalIndexExists, + dispatchToaster, + ]); const prePackagedRuleStatus = useMemo( () => diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx index 706c2645a4ddd..3b84558d344e7 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx @@ -8,6 +8,7 @@ import { useEffect, useState } from 'react'; import { errorToToaster, useStateToaster } from '../../../../common/components/toasters'; import { fetchRuleById } from './api'; +import { transformInput } from './transforms'; import * as i18n from './translations'; import { Rule } from './types'; @@ -28,13 +29,15 @@ export const useRule = (id: string | undefined): ReturnRule => { let isSubscribed = true; const abortCtrl = new AbortController(); - async function fetchData(idToFetch: string) { + const fetchData = async (idToFetch: string) => { try { setLoading(true); - const ruleResponse = await fetchRuleById({ - id: idToFetch, - signal: abortCtrl.signal, - }); + const ruleResponse = transformInput( + await fetchRuleById({ + id: idToFetch, + signal: abortCtrl.signal, + }) + ); if (isSubscribed) { setRule(ruleResponse); } @@ -47,7 +50,7 @@ export const useRule = (id: string | undefined): ReturnRule => { if (isSubscribed) { setLoading(false); } - } + }; if (id != null) { fetchData(id); } @@ -55,8 +58,7 @@ export const useRule = (id: string | undefined): ReturnRule => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]); + }, [id, dispatchToaster]); return [loading, rule]; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx index fbca46097dcd9..48bfe71b4722b 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx @@ -6,12 +6,14 @@ import { useEffect, useCallback } from 'react'; +import { flow } from 'fp-ts/lib/function'; import { useAsync, withOptionalSignal } from '../../../../shared_imports'; import { useHttp } from '../../../../common/lib/kibana'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { pureFetchRuleById } from './api'; import { Rule } from './types'; import * as i18n from './translations'; +import { transformInput } from './transforms'; export interface UseRuleAsync { error: unknown; @@ -20,11 +22,15 @@ export interface UseRuleAsync { rule: Rule | null; } -const _fetchRule = withOptionalSignal(pureFetchRuleById); -const _useRuleAsync = () => useAsync(_fetchRule); +const _fetchRule = flow(withOptionalSignal(pureFetchRuleById), async (rule: Promise) => + transformInput(await rule) +); + +/** This does not use "_useRuleAsyncInternal" as that would deactivate the useHooks linter rule, so instead it has the word "Internal" post-pended */ +const useRuleAsyncInternal = () => useAsync(_fetchRule); export const useRuleAsync = (ruleId: string): UseRuleAsync => { - const { start, loading, result, error } = _useRuleAsync(); + const { start, loading, result, error } = useRuleAsyncInternal(); const http = useHttp(); const { addError } = useAppToasts(); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx index ddf50e9edae51..2bec8f9a2d0a2 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx @@ -64,8 +64,7 @@ export const useRuleStatus = (id: string | undefined | null): ReturnRuleStatus = isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]); + }, [id, dispatchToaster]); return [loading, ruleStatus, fetchRuleStatus.current]; }; @@ -122,8 +121,7 @@ export const useRulesStatuses = (rules: Rules): ReturnRulesStatuses => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rules]); + }, [rules, dispatchToaster]); return { loading, rulesStatuses }; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_tags.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_tags.tsx index 038f974e1394e..bab419813e1aa 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_tags.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_tags.tsx @@ -26,7 +26,7 @@ export const useTags = (): ReturnTags => { let isSubscribed = true; const abortCtrl = new AbortController(); - async function fetchData() { + const fetchData = async () => { setLoading(true); try { const fetchTagsResult = await fetchTags({ @@ -44,7 +44,7 @@ export const useTags = (): ReturnTags => { if (isSubscribed) { setLoading(false); } - } + }; fetchData(); reFetchTags.current = fetchData; @@ -53,8 +53,7 @@ export const useTags = (): ReturnTags => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [dispatchToaster]); return [loading, tags, reFetchTags.current]; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx index a437974e93ba3..729336b697e4d 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx @@ -9,6 +9,8 @@ import { useEffect, useState, Dispatch } from 'react'; import { errorToToaster, useStateToaster } from '../../../../common/components/toasters'; import { UpdateRulesSchema } from '../../../../../common/detection_engine/schemas/request'; +import { transformOutput } from './transforms'; + import { updateRule } from './api'; import * as i18n from './translations'; @@ -29,11 +31,11 @@ export const useUpdateRule = (): ReturnUpdateRule => { let isSubscribed = true; const abortCtrl = new AbortController(); setIsSaved(false); - async function saveRule() { + const saveRule = async () => { if (rule != null) { try { setIsLoading(true); - await updateRule({ rule, signal: abortCtrl.signal }); + await updateRule({ rule: transformOutput(rule), signal: abortCtrl.signal }); if (isSubscribed) { setIsSaved(true); } @@ -46,15 +48,14 @@ export const useUpdateRule = (): ReturnUpdateRule => { setIsLoading(false); } } - } + }; saveRule(); return () => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rule]); + }, [rule, dispatchToaster]); return [{ isLoading, isSaved }, setRule]; }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx index 0d585b4463815..86f24594fc57e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx @@ -356,19 +356,20 @@ export const getMonitoringColumns = ( truncateText: true, width: '14%', }, - { - field: 'current_status.last_look_back_date', - name: i18n.COLUMN_LAST_LOOKBACK_DATE, - render: (value: RuleStatus['current_status']['last_look_back_date']) => { - return value == null ? ( - getEmptyTagValue() - ) : ( - - ); - }, - truncateText: true, - width: '16%', - }, + // hiding this field until after 7.11 release + // { + // field: 'current_status.last_look_back_date', + // name: i18n.COLUMN_LAST_LOOKBACK_DATE, + // render: (value: RuleStatus['current_status']['last_look_back_date']) => { + // return value == null ? ( + // getEmptyTagValue() + // ) : ( + // + // ); + // }, + // truncateText: true, + // width: '16%', + // }, { field: 'current_status.status_date', name: i18n.COLUMN_LAST_COMPLETE_RUN, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index 2d993c7be08b0..f7066cd42e4c1 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -353,7 +353,7 @@ export const COLUMN_QUERY_TIMES = i18n.translate( export const COLUMN_GAP = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.columns.gap', { - defaultMessage: 'Gap (if any)', + defaultMessage: 'Last Gap (if any)', } ); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/index.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/index.ts deleted file mode 100644 index 8ba632633a3af..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { lazy } from 'react'; -import { i18n } from '@kbn/i18n'; -import { validateExpression } from './validation'; -import { GeoThresholdAlertParams } from './types'; -import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; - -export function getAlertType(): AlertTypeModel { - return { - id: '.geo-threshold', - description: i18n.translate('xpack.stackAlerts.geoThreshold.descriptionText', { - defaultMessage: 'Alert when an entity enters or leaves a geo boundary.', - }), - iconClass: 'globe', - // TODO: Add documentation for geo threshold alert - documentationUrl: null, - alertParamsExpression: lazy(() => import('./query_builder')), - validate: validateExpression, - requiresAppContext: false, - }; -} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/__snapshots__/geo_threshold_alert_type_expression.test.tsx.snap b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/__snapshots__/geo_threshold_alert_type_expression.test.tsx.snap deleted file mode 100644 index ce59adc688c36..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/__snapshots__/geo_threshold_alert_type_expression.test.tsx.snap +++ /dev/null @@ -1,240 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render BoundaryIndexExpression 1`] = ` - - - - - - - - - - - - } -/> -`; - -exports[`should render EntityIndexExpression 1`] = ` - - - - - - } - labelType="label" - > - - - - - - - } -/> -`; - -exports[`should render EntityIndexExpression w/ invalid flag if invalid 1`] = ` - - - - - - } - labelType="label" - > - - - - - - - } -/> -`; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/boundary_index_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/boundary_index_expression.tsx deleted file mode 100644 index 93918c82d664c..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/boundary_index_expression.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Fragment, FunctionComponent, useEffect, useRef } from 'react'; -import { EuiFormRow } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { DataPublicPluginStart } from 'src/plugins/data/public'; -import { HttpSetup } from 'kibana/public'; -import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { IErrorObject } from '../../../../../../triggers_actions_ui/public'; -import { ES_GEO_SHAPE_TYPES, GeoThresholdAlertParams } from '../../types'; -import { GeoIndexPatternSelect } from '../util_components/geo_index_pattern_select'; -import { SingleFieldSelect } from '../util_components/single_field_select'; -import { ExpressionWithPopover } from '../util_components/expression_with_popover'; -import { IFieldType } from '../../../../../../../../src/plugins/data/common/index_patterns/fields'; -import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns'; - -interface Props { - alertParams: GeoThresholdAlertParams; - errors: IErrorObject; - boundaryIndexPattern: IIndexPattern; - boundaryNameField?: string; - setBoundaryIndexPattern: (boundaryIndexPattern?: IIndexPattern) => void; - setBoundaryGeoField: (boundaryGeoField?: string) => void; - setBoundaryNameField: (boundaryNameField?: string) => void; - data: DataPublicPluginStart; -} - -interface KibanaDeps { - http: HttpSetup; -} - -export const BoundaryIndexExpression: FunctionComponent = ({ - alertParams, - errors, - boundaryIndexPattern, - boundaryNameField, - setBoundaryIndexPattern, - setBoundaryGeoField, - setBoundaryNameField, - data, -}) => { - // eslint-disable-next-line react-hooks/exhaustive-deps - const BOUNDARY_NAME_ENTITY_TYPES = ['string', 'number', 'ip']; - const { http } = useKibana().services; - const IndexPatternSelect = (data.ui && data.ui.IndexPatternSelect) || null; - const { boundaryGeoField } = alertParams; - // eslint-disable-next-line react-hooks/exhaustive-deps - const nothingSelected: IFieldType = { - name: '', - type: 'string', - }; - - const usePrevious = (value: T): T | undefined => { - const ref = useRef(); - useEffect(() => { - ref.current = value; - }); - return ref.current; - }; - - const oldIndexPattern = usePrevious(boundaryIndexPattern); - const fields = useRef<{ - geoFields: IFieldType[]; - boundaryNameFields: IFieldType[]; - }>({ - geoFields: [], - boundaryNameFields: [], - }); - useEffect(() => { - if (oldIndexPattern !== boundaryIndexPattern) { - fields.current.geoFields = - (boundaryIndexPattern.fields.length && - boundaryIndexPattern.fields.filter((field: IFieldType) => - ES_GEO_SHAPE_TYPES.includes(field.type) - )) || - []; - if (fields.current.geoFields.length) { - setBoundaryGeoField(fields.current.geoFields[0].name); - } - - fields.current.boundaryNameFields = [ - ...boundaryIndexPattern.fields.filter((field: IFieldType) => { - return ( - BOUNDARY_NAME_ENTITY_TYPES.includes(field.type) && - !field.name.startsWith('_') && - !field.name.endsWith('keyword') - ); - }), - nothingSelected, - ]; - if (fields.current.boundaryNameFields.length) { - setBoundaryNameField(fields.current.boundaryNameFields[0].name); - } - } - }, [ - BOUNDARY_NAME_ENTITY_TYPES, - boundaryIndexPattern, - nothingSelected, - oldIndexPattern, - setBoundaryGeoField, - setBoundaryNameField, - ]); - - const indexPopover = ( - - - { - if (!_indexPattern) { - return; - } - setBoundaryIndexPattern(_indexPattern); - }} - value={boundaryIndexPattern.id} - IndexPatternSelectComponent={IndexPatternSelect} - indexPatternService={data.indexPatterns} - http={http} - includedGeoTypes={ES_GEO_SHAPE_TYPES} - /> - - - - - - { - setBoundaryNameField(name === nothingSelected.name ? undefined : name); - }} - fields={fields.current.boundaryNameFields} - /> - - - ); - - return ( - - ); -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_by_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_by_expression.tsx deleted file mode 100644 index 0cff207e674e5..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_by_expression.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { FunctionComponent, useEffect, useRef } from 'react'; -import { EuiFormRow } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import _ from 'lodash'; -import { IErrorObject } from '../../../../../../triggers_actions_ui/public'; -import { SingleFieldSelect } from '../util_components/single_field_select'; -import { ExpressionWithPopover } from '../util_components/expression_with_popover'; -import { IFieldType } from '../../../../../../../../src/plugins/data/common/index_patterns/fields'; - -interface Props { - errors: IErrorObject; - entity: string; - setAlertParamsEntity: (entity: string) => void; - indexFields: IFieldType[]; - isInvalid: boolean; -} - -export const EntityByExpression: FunctionComponent = ({ - errors, - entity, - setAlertParamsEntity, - indexFields, - isInvalid, -}) => { - // eslint-disable-next-line react-hooks/exhaustive-deps - const ENTITY_TYPES = ['string', 'number', 'ip']; - - const usePrevious = (value: T): T | undefined => { - const ref = useRef(); - useEffect(() => { - ref.current = value; - }); - return ref.current; - }; - - const oldIndexFields = usePrevious(indexFields); - const fields = useRef<{ - indexFields: IFieldType[]; - }>({ - indexFields: [], - }); - useEffect(() => { - if (!_.isEqual(oldIndexFields, indexFields)) { - fields.current.indexFields = indexFields.filter( - (field: IFieldType) => ENTITY_TYPES.includes(field.type) && !field.name.startsWith('_') - ); - if (!entity && fields.current.indexFields.length) { - setAlertParamsEntity(fields.current.indexFields[0].name); - } - } - }, [ENTITY_TYPES, indexFields, oldIndexFields, setAlertParamsEntity, entity]); - - const indexPopover = ( - - _entity && setAlertParamsEntity(_entity)} - fields={fields.current.indexFields} - /> - - ); - - return ( - - ); -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_index_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_index_expression.tsx deleted file mode 100644 index f2d2f7848a4f9..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_index_expression.tsx +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Fragment, FunctionComponent, useEffect, useRef } from 'react'; -import { EuiFormRow } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { DataPublicPluginStart } from 'src/plugins/data/public'; -import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { - IErrorObject, - AlertTypeParamsExpressionProps, -} from '../../../../../../triggers_actions_ui/public'; -import { ES_GEO_FIELD_TYPES } from '../../types'; -import { GeoIndexPatternSelect } from '../util_components/geo_index_pattern_select'; -import { SingleFieldSelect } from '../util_components/single_field_select'; -import { ExpressionWithPopover } from '../util_components/expression_with_popover'; -import { IFieldType } from '../../../../../../../../src/plugins/data/common/index_patterns/fields'; -import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns'; - -interface Props { - dateField: string; - geoField: string; - errors: IErrorObject; - setAlertParamsDate: (date: string) => void; - setAlertParamsGeoField: (geoField: string) => void; - setAlertProperty: AlertTypeParamsExpressionProps['setAlertProperty']; - setIndexPattern: (indexPattern: IIndexPattern) => void; - indexPattern: IIndexPattern; - isInvalid: boolean; - data: DataPublicPluginStart; -} - -export const EntityIndexExpression: FunctionComponent = ({ - setAlertParamsDate, - setAlertParamsGeoField, - errors, - setIndexPattern, - indexPattern, - isInvalid, - dateField: timeField, - geoField, - data, -}) => { - const { http } = useKibana().services; - const IndexPatternSelect = (data.ui && data.ui.IndexPatternSelect) || null; - - const usePrevious = (value: T): T | undefined => { - const ref = useRef(); - useEffect(() => { - ref.current = value; - }); - return ref.current; - }; - - const oldIndexPattern = usePrevious(indexPattern); - const fields = useRef<{ - dateFields: IFieldType[]; - geoFields: IFieldType[]; - }>({ - dateFields: [], - geoFields: [], - }); - useEffect(() => { - if (oldIndexPattern !== indexPattern) { - fields.current.geoFields = - (indexPattern.fields.length && - indexPattern.fields.filter((field: IFieldType) => - ES_GEO_FIELD_TYPES.includes(field.type) - )) || - []; - if (fields.current.geoFields.length) { - setAlertParamsGeoField(fields.current.geoFields[0].name); - } - - fields.current.dateFields = - (indexPattern.fields.length && - indexPattern.fields.filter((field: IFieldType) => field.type === 'date')) || - []; - if (fields.current.dateFields.length) { - setAlertParamsDate(fields.current.dateFields[0].name); - } - } - }, [indexPattern, oldIndexPattern, setAlertParamsDate, setAlertParamsGeoField]); - - const indexPopover = ( - - - { - // reset time field and expression fields if indices are deleted - if (!_indexPattern) { - return; - } - setIndexPattern(_indexPattern); - }} - value={indexPattern.id} - IndexPatternSelectComponent={IndexPatternSelect} - indexPatternService={data.indexPatterns} - http={http!} - includedGeoTypes={ES_GEO_FIELD_TYPES} - /> - - - } - > - - _timeField && setAlertParamsDate(_timeField) - } - fields={fields.current.dateFields} - /> - - - - _geoField && setAlertParamsGeoField(_geoField) - } - fields={fields.current.geoFields} - /> - - - ); - - return ( - - ); -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/geo_threshold_alert_type_expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/geo_threshold_alert_type_expression.test.tsx deleted file mode 100644 index c8158b0a6feaa..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/geo_threshold_alert_type_expression.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { shallow } from 'enzyme'; -import { EntityIndexExpression } from './expressions/entity_index_expression'; -import { BoundaryIndexExpression } from './expressions/boundary_index_expression'; -import { IErrorObject } from '../../../../../triggers_actions_ui/public'; -import { IIndexPattern } from '../../../../../../../src/plugins/data/common'; -import { dataPluginMock } from 'src/plugins/data/public/mocks'; - -const alertParams = { - index: '', - indexId: '', - geoField: '', - entity: '', - dateField: '', - trackingEvent: '', - boundaryType: '', - boundaryIndexTitle: '', - boundaryIndexId: '', - boundaryGeoField: '', -}; - -const dataStartMock = dataPluginMock.createStartContract(); - -test('should render EntityIndexExpression', async () => { - const component = shallow( - {}} - setAlertParamsGeoField={() => {}} - setAlertProperty={() => {}} - setIndexPattern={() => {}} - indexPattern={('' as unknown) as IIndexPattern} - isInvalid={false} - data={dataStartMock} - /> - ); - - expect(component).toMatchSnapshot(); -}); - -test('should render EntityIndexExpression w/ invalid flag if invalid', async () => { - const component = shallow( - {}} - setAlertParamsGeoField={() => {}} - setAlertProperty={() => {}} - setIndexPattern={() => {}} - indexPattern={('' as unknown) as IIndexPattern} - isInvalid={true} - data={dataStartMock} - /> - ); - - expect(component).toMatchSnapshot(); -}); - -test('should render BoundaryIndexExpression', async () => { - const component = shallow( - {}} - setBoundaryGeoField={() => {}} - setBoundaryNameField={() => {}} - boundaryNameField={'testNameField'} - data={dataStartMock} - /> - ); - - expect(component).toMatchSnapshot(); -}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/index.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/index.tsx deleted file mode 100644 index 2a08a4b32f076..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/index.tsx +++ /dev/null @@ -1,386 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Fragment, useEffect, useState } from 'react'; -import { - EuiCallOut, - EuiFieldNumber, - EuiFlexGrid, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiIconTip, - EuiSelect, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { - AlertTypeParamsExpressionProps, - getTimeOptions, -} from '../../../../../triggers_actions_ui/public'; -import { GeoThresholdAlertParams, TrackingEvent } from '../types'; -import { ExpressionWithPopover } from './util_components/expression_with_popover'; -import { EntityIndexExpression } from './expressions/entity_index_expression'; -import { EntityByExpression } from './expressions/entity_by_expression'; -import { BoundaryIndexExpression } from './expressions/boundary_index_expression'; -import { IIndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns'; -import { - esQuery, - esKuery, - Query, - QueryStringInput, -} from '../../../../../../../src/plugins/data/public'; - -const DEFAULT_VALUES = { - TRACKING_EVENT: '', - ENTITY: '', - INDEX: '', - INDEX_ID: '', - DATE_FIELD: '', - BOUNDARY_TYPE: 'entireIndex', // Only one supported currently. Will eventually be more - GEO_FIELD: '', - BOUNDARY_INDEX: '', - BOUNDARY_INDEX_ID: '', - BOUNDARY_GEO_FIELD: '', - BOUNDARY_NAME_FIELD: '', - DELAY_OFFSET_WITH_UNITS: '0m', -}; - -const conditionOptions = Object.keys(TrackingEvent).map((key) => ({ - text: TrackingEvent[key as TrackingEvent], - value: TrackingEvent[key as TrackingEvent], -})); - -const labelForDelayOffset = ( - <> - {' '} - - -); - -function validateQuery(query: Query) { - try { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - query.language === 'kuery' - ? esKuery.fromKueryExpression(query.query) - : esQuery.luceneStringToDsl(query.query); - } catch (err) { - return false; - } - return true; -} - -export const GeoThresholdAlertTypeExpression: React.FunctionComponent< - AlertTypeParamsExpressionProps -> = ({ alertParams, alertInterval, setAlertParams, setAlertProperty, errors, data }) => { - const { - index, - indexId, - indexQuery, - geoField, - entity, - dateField, - trackingEvent, - boundaryType, - boundaryIndexTitle, - boundaryIndexId, - boundaryIndexQuery, - boundaryGeoField, - boundaryNameField, - delayOffsetWithUnits, - } = alertParams; - - const [indexPattern, _setIndexPattern] = useState({ - id: '', - fields: [], - title: '', - }); - const setIndexPattern = (_indexPattern?: IIndexPattern) => { - if (_indexPattern) { - _setIndexPattern(_indexPattern); - if (_indexPattern.title) { - setAlertParams('index', _indexPattern.title); - } - if (_indexPattern.id) { - setAlertParams('indexId', _indexPattern.id); - } - } - }; - const [indexQueryInput, setIndexQueryInput] = useState( - indexQuery || { - query: '', - language: 'kuery', - } - ); - const [boundaryIndexPattern, _setBoundaryIndexPattern] = useState({ - id: '', - fields: [], - title: '', - }); - const setBoundaryIndexPattern = (_indexPattern?: IIndexPattern) => { - if (_indexPattern) { - _setBoundaryIndexPattern(_indexPattern); - if (_indexPattern.title) { - setAlertParams('boundaryIndexTitle', _indexPattern.title); - } - if (_indexPattern.id) { - setAlertParams('boundaryIndexId', _indexPattern.id); - } - } - }; - const [boundaryIndexQueryInput, setBoundaryIndexQueryInput] = useState( - boundaryIndexQuery || { - query: '', - language: 'kuery', - } - ); - const [delayOffset, _setDelayOffset] = useState(0); - function setDelayOffset(_delayOffset: number) { - setAlertParams('delayOffsetWithUnits', `${_delayOffset}${delayOffsetUnit}`); - _setDelayOffset(_delayOffset); - } - const [delayOffsetUnit, setDelayOffsetUnit] = useState('m'); - - const hasExpressionErrors = false; - const expressionErrorMessage = i18n.translate( - 'xpack.stackAlerts.geoThreshold.fixErrorInExpressionBelowValidationMessage', - { - defaultMessage: 'Expression contains errors.', - } - ); - - useEffect(() => { - const initToDefaultParams = async () => { - setAlertProperty('params', { - ...alertParams, - index: index ?? DEFAULT_VALUES.INDEX, - indexId: indexId ?? DEFAULT_VALUES.INDEX_ID, - entity: entity ?? DEFAULT_VALUES.ENTITY, - dateField: dateField ?? DEFAULT_VALUES.DATE_FIELD, - trackingEvent: trackingEvent ?? DEFAULT_VALUES.TRACKING_EVENT, - boundaryType: boundaryType ?? DEFAULT_VALUES.BOUNDARY_TYPE, - geoField: geoField ?? DEFAULT_VALUES.GEO_FIELD, - boundaryIndexTitle: boundaryIndexTitle ?? DEFAULT_VALUES.BOUNDARY_INDEX, - boundaryIndexId: boundaryIndexId ?? DEFAULT_VALUES.BOUNDARY_INDEX_ID, - boundaryGeoField: boundaryGeoField ?? DEFAULT_VALUES.BOUNDARY_GEO_FIELD, - boundaryNameField: boundaryNameField ?? DEFAULT_VALUES.BOUNDARY_NAME_FIELD, - delayOffsetWithUnits: delayOffsetWithUnits ?? DEFAULT_VALUES.DELAY_OFFSET_WITH_UNITS, - }); - if (!data?.indexPatterns) { - return; - } - if (indexId) { - const _indexPattern = await data?.indexPatterns.get(indexId); - setIndexPattern(_indexPattern); - } - if (boundaryIndexId) { - const _boundaryIndexPattern = await data?.indexPatterns.get(boundaryIndexId); - setBoundaryIndexPattern(_boundaryIndexPattern); - } - if (delayOffsetWithUnits) { - setDelayOffset(+delayOffsetWithUnits.replace(/\D/g, '')); - } - }; - initToDefaultParams(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - - {hasExpressionErrors ? ( - - - - - - ) : null} - - -
- -
-
- - - - - - - { - setDelayOffset(+e.target.value); - }} - /> - - - { - setDelayOffsetUnit(e.target.value); - }} - /> - - - - - - - -
- -
-
- - setAlertParams('dateField', _date)} - setAlertParamsGeoField={(_geoField) => setAlertParams('geoField', _geoField)} - setAlertProperty={setAlertProperty} - setIndexPattern={setIndexPattern} - indexPattern={indexPattern} - isInvalid={!indexId || !dateField || !geoField} - data={data} - /> - setAlertParams('entity', entityName)} - indexFields={indexPattern.fields} - isInvalid={indexId && dateField && geoField ? !entity : false} - /> - - - { - if (query.language) { - if (validateQuery(query)) { - setAlertParams('indexQuery', query); - } - setIndexQueryInput(query); - } - }} - /> - - - - -
- -
-
- - -
- setAlertParams('trackingEvent', e.target.value)} - options={conditionOptions} - /> -
- - } - expressionDescription={i18n.translate('xpack.stackAlerts.geoThreshold.whenEntityLabel', { - defaultMessage: 'when entity', - })} - /> - - - -
- -
-
- - - _geoField && setAlertParams('boundaryGeoField', _geoField) - } - setBoundaryNameField={(_boundaryNameField: string | undefined) => - _boundaryNameField - ? setAlertParams('boundaryNameField', _boundaryNameField) - : setAlertParams('boundaryNameField', '') - } - boundaryNameField={boundaryNameField} - data={data} - /> - - - { - if (query.language) { - if (validateQuery(query)) { - setAlertParams('boundaryIndexQuery', query); - } - setBoundaryIndexQueryInput(query); - } - }} - /> - - -
- ); -}; - -// eslint-disable-next-line import/no-default-export -export { GeoThresholdAlertTypeExpression as default }; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/expression_with_popover.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/expression_with_popover.tsx deleted file mode 100644 index a83667cfd92c6..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/expression_with_popover.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { ReactNode, useState } from 'react'; -import { - EuiButtonIcon, - EuiExpression, - EuiFlexGroup, - EuiFlexItem, - EuiPopover, - EuiPopoverTitle, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -export const ExpressionWithPopover: ({ - popoverContent, - expressionDescription, - defaultValue, - value, - isInvalid, -}: { - popoverContent: ReactNode; - expressionDescription: ReactNode; - defaultValue?: ReactNode; - value?: ReactNode; - isInvalid?: boolean; -}) => JSX.Element = ({ popoverContent, expressionDescription, defaultValue, value, isInvalid }) => { - const [popoverOpen, setPopoverOpen] = useState(false); - - return ( - setPopoverOpen(true)} - isInvalid={isInvalid} - /> - } - isOpen={popoverOpen} - closePopover={() => setPopoverOpen(false)} - ownFocus - anchorPosition="downLeft" - zIndex={8000} - display="block" - > -
- - - {expressionDescription} - - setPopoverOpen(false)} - /> - - - - {popoverContent} -
-
- ); -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/geo_index_pattern_select.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/geo_index_pattern_select.tsx deleted file mode 100644 index a552d6d998c7e..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/geo_index_pattern_select.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Component } from 'react'; -import { EuiCallOut, EuiFormRow, EuiLink, EuiSpacer } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { IndexPattern, IndexPatternsContract } from 'src/plugins/data/public'; -import { HttpSetup } from 'kibana/public'; - -interface Props { - onChange: (indexPattern: IndexPattern) => void; - value: string | undefined; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - IndexPatternSelectComponent: any; - indexPatternService: IndexPatternsContract | undefined; - http: HttpSetup; - includedGeoTypes: string[]; -} - -interface State { - noGeoIndexPatternsExist: boolean; -} - -export class GeoIndexPatternSelect extends Component { - private _isMounted: boolean = false; - - state = { - noGeoIndexPatternsExist: false, - }; - - componentWillUnmount() { - this._isMounted = false; - } - - componentDidMount() { - this._isMounted = true; - } - - _onIndexPatternSelect = async (indexPatternId: string) => { - if (!indexPatternId || indexPatternId.length === 0 || !this.props.indexPatternService) { - return; - } - - let indexPattern; - try { - indexPattern = await this.props.indexPatternService.get(indexPatternId); - } catch (err) { - return; - } - - // method may be called again before 'get' returns - // ignore response when fetched index pattern does not match active index pattern - if (this._isMounted && indexPattern.id === indexPatternId) { - this.props.onChange(indexPattern); - } - }; - - _onNoIndexPatterns = () => { - this.setState({ noGeoIndexPatternsExist: true }); - }; - - _renderNoIndexPatternWarning() { - if (!this.state.noGeoIndexPatternsExist) { - return null; - } - - return ( - <> - -

- - - - - -

-

- - - - -

-
- - - ); - } - - render() { - const IndexPatternSelectComponent = this.props.IndexPatternSelectComponent; - return ( - <> - {this._renderNoIndexPatternWarning()} - - - {IndexPatternSelectComponent ? ( - - ) : ( -
- )} - - - ); - } -} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/single_field_select.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/single_field_select.tsx deleted file mode 100644 index ef6e6f6f5e18f..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/single_field_select.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import _ from 'lodash'; -import React from 'react'; -import { - EuiComboBox, - EuiComboBoxOptionOption, - EuiHighlight, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; -import { IFieldType } from 'src/plugins/data/public'; -import { FieldIcon } from '../../../../../../../../src/plugins/kibana_react/public'; - -function fieldsToOptions(fields?: IFieldType[]): Array> { - if (!fields) { - return []; - } - - return fields - .map((field) => ({ - value: field, - label: field.name, - })) - .sort((a, b) => { - return a.label.toLowerCase().localeCompare(b.label.toLowerCase()); - }); -} - -interface Props { - placeholder: string; - value: string | null; // index pattern field name - onChange: (fieldName?: string) => void; - fields: IFieldType[]; -} - -export function SingleFieldSelect({ placeholder, value, onChange, fields }: Props) { - function renderOption( - option: EuiComboBoxOptionOption, - searchValue: string, - contentClassName: string - ) { - return ( - - - - - - {option.label} - - - ); - } - - const onSelection = (selectedOptions: Array>) => { - onChange(_.get(selectedOptions, '0.value.name')); - }; - - const selectedOptions: Array> = []; - if (value && fields) { - const selectedField = fields.find((field: IFieldType) => field.name === value); - if (selectedField) { - selectedOptions.push({ value: selectedField, label: value }); - } - } - - return ( - - ); -} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/types.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/types.ts deleted file mode 100644 index 3f487135f0474..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { AlertTypeParams } from '../../../../alerts/common'; -import { Query } from '../../../../../../src/plugins/data/common'; - -export enum TrackingEvent { - entered = 'entered', - exited = 'exited', - crossed = 'crossed', -} - -export interface GeoThresholdAlertParams extends AlertTypeParams { - index: string; - indexId: string; - geoField: string; - entity: string; - dateField: string; - trackingEvent: string; - boundaryType: string; - boundaryIndexTitle: string; - boundaryIndexId: string; - boundaryGeoField: string; - boundaryNameField?: string; - delayOffsetWithUnits?: string; - indexQuery?: Query; - boundaryIndexQuery?: Query; -} - -// Will eventually include 'geo_shape' -export const ES_GEO_FIELD_TYPES = ['geo_point']; -export const ES_GEO_SHAPE_TYPES = ['geo_shape']; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.test.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.test.ts deleted file mode 100644 index 9cc5b1eb069ae..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { GeoThresholdAlertParams } from './types'; -import { validateExpression } from './validation'; - -describe('expression params validation', () => { - test('if index property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: '', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.index.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.index[0]).toBe('Index pattern is required.'); - }); - - test('if geoField property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: '', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.geoField.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.geoField[0]).toBe('Geo field is required.'); - }); - - test('if entity property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: '', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.entity.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.entity[0]).toBe('Entity is required.'); - }); - - test('if dateField property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: '', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.dateField.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.dateField[0]).toBe('Date field is required.'); - }); - - test('if trackingEvent property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: '', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.trackingEvent.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.trackingEvent[0]).toBe( - 'Tracking event is required.' - ); - }); - - test('if boundaryType property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: '', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.boundaryType.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.boundaryType[0]).toBe( - 'Boundary type is required.' - ); - }); - - test('if boundaryIndexTitle property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: '', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.boundaryIndexTitle.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.boundaryIndexTitle[0]).toBe( - 'Boundary index pattern title is required.' - ); - }); - - test('if boundaryGeoField property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: '', - }; - expect(validateExpression(initialParams).errors.boundaryGeoField.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.boundaryGeoField[0]).toBe( - 'Boundary geo field is required.' - ); - }); - - test('if boundaryNameField property is missing should not return error', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - boundaryNameField: '', - }; - expect(validateExpression(initialParams).errors.boundaryGeoField.length).toBe(0); - }); -}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.ts deleted file mode 100644 index 7a511f681ecaa..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { i18n } from '@kbn/i18n'; -import { ValidationResult } from '../../../../triggers_actions_ui/public'; -import { GeoThresholdAlertParams } from './types'; - -export const validateExpression = (alertParams: GeoThresholdAlertParams): ValidationResult => { - const { - index, - geoField, - entity, - dateField, - trackingEvent, - boundaryType, - boundaryIndexTitle, - boundaryGeoField, - } = alertParams; - const validationResult = { errors: {} }; - const errors = { - index: new Array(), - indexId: new Array(), - geoField: new Array(), - entity: new Array(), - dateField: new Array(), - trackingEvent: new Array(), - boundaryType: new Array(), - boundaryIndexTitle: new Array(), - boundaryIndexId: new Array(), - boundaryGeoField: new Array(), - }; - validationResult.errors = errors; - - if (!index) { - errors.index.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredIndexTitleText', { - defaultMessage: 'Index pattern is required.', - }) - ); - } - - if (!geoField) { - errors.geoField.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredGeoFieldText', { - defaultMessage: 'Geo field is required.', - }) - ); - } - - if (!entity) { - errors.entity.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredEntityText', { - defaultMessage: 'Entity is required.', - }) - ); - } - - if (!dateField) { - errors.dateField.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredDateFieldText', { - defaultMessage: 'Date field is required.', - }) - ); - } - - if (!trackingEvent) { - errors.trackingEvent.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredTrackingEventText', { - defaultMessage: 'Tracking event is required.', - }) - ); - } - - if (!boundaryType) { - errors.boundaryType.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredBoundaryTypeText', { - defaultMessage: 'Boundary type is required.', - }) - ); - } - - if (!boundaryIndexTitle) { - errors.boundaryIndexTitle.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredBoundaryIndexTitleText', { - defaultMessage: 'Boundary index pattern title is required.', - }) - ); - } - - if (!boundaryGeoField) { - errors.boundaryGeoField.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredBoundaryGeoFieldText', { - defaultMessage: 'Boundary geo field is required.', - }) - ); - } - - return validationResult; -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/index.ts b/x-pack/plugins/stack_alerts/public/alert_types/index.ts index 654bf0a424f09..026383cd92f20 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/index.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/index.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getAlertType as getGeoThresholdAlertType } from './geo_threshold'; import { getAlertType as getGeoContainmentAlertType } from './geo_containment'; import { getAlertType as getThresholdAlertType } from './threshold'; import { getAlertType as getEsQueryAlertType } from './es_query'; @@ -19,7 +18,6 @@ export function registerAlertTypes({ config: Config; }) { if (config.enableGeoAlerting) { - alertTypeRegistry.register(getGeoThresholdAlertType()); alertTypeRegistry.register(getGeoContainmentAlertType()); } alertTypeRegistry.register(getThresholdAlertType()); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/alert_type.ts deleted file mode 100644 index 27478049d4880..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/alert_type.ts +++ /dev/null @@ -1,240 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import { schema } from '@kbn/config-schema'; -import { Logger } from 'src/core/server'; -import { STACK_ALERTS_FEATURE_ID } from '../../../common'; -import { getGeoThresholdExecutor } from './geo_threshold'; -import { - AlertType, - AlertTypeState, - AlertInstanceState, - AlertInstanceContext, - AlertTypeParams, -} from '../../../../alerts/server'; -import { Query } from '../../../../../../src/plugins/data/common/query'; - -export const GEO_THRESHOLD_ID = '.geo-threshold'; -export type TrackingEvent = 'entered' | 'exited'; -export const ActionGroupId = 'tracking threshold met'; - -const actionVariableContextToEntityDateTimeLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextToEntityDateTimeLabel', - { - defaultMessage: `The time the entity was detected in the current boundary`, - } -); - -const actionVariableContextFromEntityDateTimeLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDateTimeLabel', - { - defaultMessage: `The last time the entity was recorded in the previous boundary`, - } -); - -const actionVariableContextToEntityLocationLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextToEntityLocationLabel', - { - defaultMessage: 'The most recently captured location of the entity', - } -); - -const actionVariableContextCrossingLineLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextCrossingLineLabel', - { - defaultMessage: - 'GeoJSON line connecting the two locations that were used to determine the crossing event', - } -); - -const actionVariableContextFromEntityLocationLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityLocationLabel', - { - defaultMessage: 'The previously captured location of the entity', - } -); - -const actionVariableContextToBoundaryIdLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextCurrentBoundaryIdLabel', - { - defaultMessage: 'The current boundary id containing the entity (if any)', - } -); - -const actionVariableContextToBoundaryNameLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextToBoundaryNameLabel', - { - defaultMessage: 'The boundary (if any) the entity has crossed into and is currently located', - } -); - -const actionVariableContextFromBoundaryNameLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryNameLabel', - { - defaultMessage: 'The boundary (if any) the entity has crossed from and was previously located', - } -); - -const actionVariableContextFromBoundaryIdLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryIdLabel', - { - defaultMessage: 'The previous boundary id containing the entity (if any)', - } -); - -const actionVariableContextToEntityDocumentIdLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextCrossingDocumentIdLabel', - { - defaultMessage: 'The id of the crossing entity document', - } -); - -const actionVariableContextFromEntityDocumentIdLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDocumentIdLabel', - { - defaultMessage: 'The id of the crossing entity document', - } -); - -const actionVariableContextTimeOfDetectionLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextTimeOfDetectionLabel', - { - defaultMessage: 'The alert interval end time this change was recorded', - } -); - -const actionVariableContextEntityIdLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextEntityIdLabel', - { - defaultMessage: 'The entity ID of the document that triggered the alert', - } -); - -const actionVariables = { - context: [ - // Alert-specific data - { name: 'entityId', description: actionVariableContextEntityIdLabel }, - { name: 'timeOfDetection', description: actionVariableContextTimeOfDetectionLabel }, - { name: 'crossingLine', description: actionVariableContextCrossingLineLabel }, - - // Corresponds to a specific document in the entity-index - { name: 'toEntityLocation', description: actionVariableContextToEntityLocationLabel }, - { - name: 'toEntityDateTime', - description: actionVariableContextToEntityDateTimeLabel, - }, - { name: 'toEntityDocumentId', description: actionVariableContextToEntityDocumentIdLabel }, - - // Corresponds to a specific document in the boundary-index - { name: 'toBoundaryId', description: actionVariableContextToBoundaryIdLabel }, - { name: 'toBoundaryName', description: actionVariableContextToBoundaryNameLabel }, - - // Corresponds to a specific document in the entity-index (from) - { name: 'fromEntityLocation', description: actionVariableContextFromEntityLocationLabel }, - { name: 'fromEntityDateTime', description: actionVariableContextFromEntityDateTimeLabel }, - { name: 'fromEntityDocumentId', description: actionVariableContextFromEntityDocumentIdLabel }, - - // Corresponds to a specific document in the boundary-index (from) - { name: 'fromBoundaryId', description: actionVariableContextFromBoundaryIdLabel }, - { name: 'fromBoundaryName', description: actionVariableContextFromBoundaryNameLabel }, - ], -}; - -export const ParamsSchema = schema.object({ - index: schema.string({ minLength: 1 }), - indexId: schema.string({ minLength: 1 }), - geoField: schema.string({ minLength: 1 }), - entity: schema.string({ minLength: 1 }), - dateField: schema.string({ minLength: 1 }), - trackingEvent: schema.string({ minLength: 1 }), - boundaryType: schema.string({ minLength: 1 }), - boundaryIndexTitle: schema.string({ minLength: 1 }), - boundaryIndexId: schema.string({ minLength: 1 }), - boundaryGeoField: schema.string({ minLength: 1 }), - boundaryNameField: schema.maybe(schema.string({ minLength: 1 })), - delayOffsetWithUnits: schema.maybe(schema.string({ minLength: 1 })), - indexQuery: schema.maybe(schema.any({})), - boundaryIndexQuery: schema.maybe(schema.any({})), -}); - -export interface GeoThresholdParams extends AlertTypeParams { - index: string; - indexId: string; - geoField: string; - entity: string; - dateField: string; - trackingEvent: string; - boundaryType: string; - boundaryIndexTitle: string; - boundaryIndexId: string; - boundaryGeoField: string; - boundaryNameField?: string; - delayOffsetWithUnits?: string; - indexQuery?: Query; - boundaryIndexQuery?: Query; -} -export interface GeoThresholdState extends AlertTypeState { - shapesFilters: Record; - shapesIdsNamesMap: Record; - prevLocationArr: GeoThresholdInstanceState[]; -} -export interface GeoThresholdInstanceState extends AlertInstanceState { - location: number[]; - shapeLocationId: string; - entityName: string; - dateInShape: string | null; - docId: string; -} -export interface GeoThresholdInstanceContext extends AlertInstanceContext { - entityId: string; - timeOfDetection: number; - crossingLine: string; - toEntityLocation: string; - toEntityDateTime: string | null; - toEntityDocumentId: string; - toBoundaryId: string; - toBoundaryName: unknown; - fromEntityLocation: string; - fromEntityDateTime: string | null; - fromEntityDocumentId: string; - fromBoundaryId: string; - fromBoundaryName: unknown; -} - -export type GeoThresholdAlertType = AlertType< - GeoThresholdParams, - GeoThresholdState, - GeoThresholdInstanceState, - GeoThresholdInstanceContext, - typeof ActionGroupId ->; -export function getAlertType(logger: Logger): GeoThresholdAlertType { - const alertTypeName = i18n.translate('xpack.stackAlerts.geoThreshold.alertTypeTitle', { - defaultMessage: 'Tracking threshold', - }); - - const actionGroupName = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionGroupThresholdMetTitle', - { - defaultMessage: 'Tracking threshold met', - } - ); - - return { - id: GEO_THRESHOLD_ID, - name: alertTypeName, - actionGroups: [{ id: ActionGroupId, name: actionGroupName }], - defaultActionGroupId: ActionGroupId, - executor: getGeoThresholdExecutor(logger), - producer: STACK_ALERTS_FEATURE_ID, - validate: { - params: ParamsSchema, - }, - actionVariables, - minimumLicenseRequired: 'gold', - }; -} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/es_query_builder.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/es_query_builder.ts deleted file mode 100644 index 02ac19e7b6f1e..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/es_query_builder.ts +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ILegacyScopedClusterClient } from 'kibana/server'; -import { SearchResponse } from 'elasticsearch'; -import { Logger } from 'src/core/server'; -import { - Query, - IIndexPattern, - fromKueryExpression, - toElasticsearchQuery, - luceneStringToDsl, -} from '../../../../../../src/plugins/data/common'; - -export const OTHER_CATEGORY = 'other'; -// Consider dynamically obtaining from config? -const MAX_TOP_LEVEL_QUERY_SIZE = 0; -const MAX_SHAPES_QUERY_SIZE = 10000; -const MAX_BUCKETS_LIMIT = 65535; - -export const getEsFormattedQuery = (query: Query, indexPattern?: IIndexPattern) => { - let esFormattedQuery; - - const queryLanguage = query.language; - if (queryLanguage === 'kuery') { - const ast = fromKueryExpression(query.query); - esFormattedQuery = toElasticsearchQuery(ast, indexPattern); - } else { - esFormattedQuery = luceneStringToDsl(query.query); - } - return esFormattedQuery; -}; - -export async function getShapesFilters( - boundaryIndexTitle: string, - boundaryGeoField: string, - geoField: string, - callCluster: ILegacyScopedClusterClient['callAsCurrentUser'], - log: Logger, - alertId: string, - boundaryNameField?: string, - boundaryIndexQuery?: Query -) { - const filters: Record = {}; - const shapesIdsNamesMap: Record = {}; - // Get all shapes in index - const boundaryData: SearchResponse> = await callCluster('search', { - index: boundaryIndexTitle, - body: { - size: MAX_SHAPES_QUERY_SIZE, - ...(boundaryIndexQuery ? { query: getEsFormattedQuery(boundaryIndexQuery) } : {}), - }, - }); - - boundaryData.hits.hits.forEach(({ _index, _id }) => { - filters[_id] = { - geo_shape: { - [geoField]: { - indexed_shape: { - index: _index, - id: _id, - path: boundaryGeoField, - }, - }, - }, - }; - }); - if (boundaryNameField) { - boundaryData.hits.hits.forEach( - ({ _source, _id }: { _source: Record; _id: string }) => { - shapesIdsNamesMap[_id] = _source[boundaryNameField]; - } - ); - } - return { - shapesFilters: filters, - shapesIdsNamesMap, - }; -} - -export async function executeEsQueryFactory( - { - entity, - index, - dateField, - boundaryGeoField, - geoField, - boundaryIndexTitle, - indexQuery, - }: { - entity: string; - index: string; - dateField: string; - boundaryGeoField: string; - geoField: string; - boundaryIndexTitle: string; - boundaryNameField?: string; - indexQuery?: Query; - }, - { callCluster }: { callCluster: ILegacyScopedClusterClient['callAsCurrentUser'] }, - log: Logger, - shapesFilters: Record -) { - return async ( - gteDateTime: Date | null, - ltDateTime: Date | null - ): Promise | undefined> => { - let esFormattedQuery; - if (indexQuery) { - const gteEpochDateTime = gteDateTime ? new Date(gteDateTime).getTime() : null; - const ltEpochDateTime = ltDateTime ? new Date(ltDateTime).getTime() : null; - const dateRangeUpdatedQuery = - indexQuery.language === 'kuery' - ? `(${dateField} >= "${gteEpochDateTime}" and ${dateField} < "${ltEpochDateTime}") and (${indexQuery.query})` - : `(${dateField}:[${gteDateTime} TO ${ltDateTime}]) AND (${indexQuery.query})`; - esFormattedQuery = getEsFormattedQuery({ - query: dateRangeUpdatedQuery, - language: indexQuery.language, - }); - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const esQuery: Record = { - index, - body: { - size: MAX_TOP_LEVEL_QUERY_SIZE, - aggs: { - shapes: { - filters: { - other_bucket_key: OTHER_CATEGORY, - filters: shapesFilters, - }, - aggs: { - entitySplit: { - terms: { - size: MAX_BUCKETS_LIMIT / ((Object.keys(shapesFilters).length || 1) * 2), - field: entity, - }, - aggs: { - entityHits: { - top_hits: { - size: 1, - sort: [ - { - [dateField]: { - order: 'desc', - }, - }, - ], - docvalue_fields: [entity, dateField, geoField], - _source: false, - }, - }, - }, - }, - }, - }, - }, - query: esFormattedQuery - ? esFormattedQuery - : { - bool: { - must: [], - filter: [ - { - match_all: {}, - }, - { - range: { - [dateField]: { - ...(gteDateTime ? { gte: gteDateTime } : {}), - lt: ltDateTime, // 'less than' to prevent overlap between intervals - format: 'strict_date_optional_time', - }, - }, - }, - ], - should: [], - must_not: [], - }, - }, - stored_fields: ['*'], - docvalue_fields: [ - { - field: dateField, - format: 'date_time', - }, - ], - }, - }; - - let esResult: SearchResponse | undefined; - try { - esResult = await callCluster('search', esQuery); - } catch (err) { - log.warn(`${err.message}`); - } - return esResult; - }; -} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/geo_threshold.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/geo_threshold.ts deleted file mode 100644 index a2375537ae6e5..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/geo_threshold.ts +++ /dev/null @@ -1,293 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import _ from 'lodash'; -import { SearchResponse } from 'elasticsearch'; -import { Logger } from 'src/core/server'; -import { executeEsQueryFactory, getShapesFilters, OTHER_CATEGORY } from './es_query_builder'; -import { - ActionGroupId, - GEO_THRESHOLD_ID, - GeoThresholdAlertType, - GeoThresholdInstanceState, -} from './alert_type'; - -export type LatestEntityLocation = GeoThresholdInstanceState; - -// Flatten agg results and get latest locations for each entity -export function transformResults( - results: SearchResponse | undefined, - dateField: string, - geoField: string -): LatestEntityLocation[] { - if (!results) { - return []; - } - - return ( - _.chain(results) - .get('aggregations.shapes.buckets', {}) - // @ts-expect-error - .flatMap((bucket: unknown, bucketKey: string) => { - const subBuckets = _.get(bucket, 'entitySplit.buckets', []); - return _.map(subBuckets, (subBucket) => { - const locationFieldResult = _.get( - subBucket, - `entityHits.hits.hits[0].fields["${geoField}"][0]`, - '' - ); - const location = locationFieldResult - ? _.chain(locationFieldResult) - .split(', ') - .map((coordString) => +coordString) - .reverse() - .value() - : null; - const dateInShape = _.get( - subBucket, - `entityHits.hits.hits[0].fields["${dateField}"][0]`, - null - ); - const docId = _.get(subBucket, `entityHits.hits.hits[0]._id`); - - return { - location, - shapeLocationId: bucketKey, - entityName: subBucket.key, - dateInShape, - docId, - }; - }); - }) - .orderBy(['entityName', 'dateInShape'], ['asc', 'desc']) - .reduce((accu: LatestEntityLocation[], el: LatestEntityLocation) => { - if (!accu.length || el.entityName !== accu[accu.length - 1].entityName) { - accu.push(el); - } - return accu; - }, []) - .value() - ); -} - -interface EntityMovementDescriptor { - entityName: string; - currLocation: { - location: number[]; - shapeId: string; - date: string | null; - docId: string; - }; - prevLocation: { - location: number[]; - shapeId: string; - date: string | null; - docId: string; - }; -} - -export function getMovedEntities( - currLocationArr: LatestEntityLocation[], - prevLocationArr: LatestEntityLocation[], - trackingEvent: string -): EntityMovementDescriptor[] { - return ( - currLocationArr - // Check if shape has a previous location and has moved - .reduce( - ( - accu: EntityMovementDescriptor[], - { - entityName, - shapeLocationId, - dateInShape, - location, - docId, - }: { - entityName: string; - shapeLocationId: string; - dateInShape: string | null; - location: number[]; - docId: string; - } - ) => { - const prevLocationObj = prevLocationArr.find( - (locationObj: LatestEntityLocation) => locationObj.entityName === entityName - ); - if (!prevLocationObj) { - return accu; - } - if (shapeLocationId !== prevLocationObj.shapeLocationId) { - accu.push({ - entityName, - currLocation: { - location, - shapeId: shapeLocationId, - date: dateInShape, - docId, - }, - prevLocation: { - location: prevLocationObj.location, - shapeId: prevLocationObj.shapeLocationId, - date: prevLocationObj.dateInShape, - docId: prevLocationObj.docId, - }, - }); - } - return accu; - }, - [] - ) - // Do not track entries to or exits from 'other' - .filter((entityMovementDescriptor: EntityMovementDescriptor) => { - if (trackingEvent !== 'crossed') { - return trackingEvent === 'entered' - ? entityMovementDescriptor.currLocation.shapeId !== OTHER_CATEGORY - : entityMovementDescriptor.prevLocation.shapeId !== OTHER_CATEGORY; - } - return true; - }) - ); -} - -function getOffsetTime(delayOffsetWithUnits: string, oldTime: Date): Date { - const timeUnit = delayOffsetWithUnits.slice(-1); - const time: number = +delayOffsetWithUnits.slice(0, -1); - - const adjustedDate = new Date(oldTime.getTime()); - if (timeUnit === 's') { - adjustedDate.setSeconds(adjustedDate.getSeconds() - time); - } else if (timeUnit === 'm') { - adjustedDate.setMinutes(adjustedDate.getMinutes() - time); - } else if (timeUnit === 'h') { - adjustedDate.setHours(adjustedDate.getHours() - time); - } else if (timeUnit === 'd') { - adjustedDate.setDate(adjustedDate.getDate() - time); - } - return adjustedDate; -} - -export const getGeoThresholdExecutor = (log: Logger): GeoThresholdAlertType['executor'] => - async function ({ previousStartedAt, startedAt, services, params, alertId, state }) { - const { shapesFilters, shapesIdsNamesMap } = state.shapesFilters - ? state - : await getShapesFilters( - params.boundaryIndexTitle, - params.boundaryGeoField, - params.geoField, - services.callCluster, - log, - alertId, - params.boundaryNameField, - params.boundaryIndexQuery - ); - - const executeEsQuery = await executeEsQueryFactory(params, services, log, shapesFilters); - - let currIntervalStartTime = previousStartedAt; - let currIntervalEndTime = startedAt; - if (params.delayOffsetWithUnits) { - if (currIntervalStartTime) { - currIntervalStartTime = getOffsetTime(params.delayOffsetWithUnits, currIntervalStartTime); - } - currIntervalEndTime = getOffsetTime(params.delayOffsetWithUnits, currIntervalEndTime); - } - - // Start collecting data only on the first cycle - if (!currIntervalStartTime) { - log.debug(`alert ${GEO_THRESHOLD_ID}:${alertId} alert initialized. Collecting data`); - // Consider making first time window configurable? - const tempPreviousEndTime = new Date(currIntervalEndTime); - tempPreviousEndTime.setMinutes(tempPreviousEndTime.getMinutes() - 5); - const prevToCurrentIntervalResults: - | SearchResponse - | undefined = await executeEsQuery(tempPreviousEndTime, currIntervalEndTime); - return { - prevLocationArr: transformResults( - prevToCurrentIntervalResults, - params.dateField, - params.geoField - ), - shapesFilters, - shapesIdsNamesMap, - }; - } - - const currentIntervalResults: SearchResponse | undefined = await executeEsQuery( - currIntervalStartTime, - currIntervalEndTime - ); - // No need to compare if no changes in current interval - if (!_.get(currentIntervalResults, 'hits.total.value')) { - return state; - } - - const currLocationArr: LatestEntityLocation[] = transformResults( - currentIntervalResults, - params.dateField, - params.geoField - ); - - const movedEntities: EntityMovementDescriptor[] = getMovedEntities( - currLocationArr, - state.prevLocationArr, - params.trackingEvent - ); - - // Create alert instances - movedEntities.forEach(({ entityName, currLocation, prevLocation }) => { - const toBoundaryName = shapesIdsNamesMap[currLocation.shapeId] || currLocation.shapeId; - const fromBoundaryName = shapesIdsNamesMap[prevLocation.shapeId] || prevLocation.shapeId; - let alertInstance; - if (params.trackingEvent === 'entered') { - alertInstance = `${entityName}-${toBoundaryName || currLocation.shapeId}`; - } else if (params.trackingEvent === 'exited') { - alertInstance = `${entityName}-${fromBoundaryName || prevLocation.shapeId}`; - } else { - // == 'crossed' - alertInstance = `${entityName}-${fromBoundaryName || prevLocation.shapeId}-${ - toBoundaryName || currLocation.shapeId - }`; - } - services.alertInstanceFactory(alertInstance).scheduleActions(ActionGroupId, { - entityId: entityName, - timeOfDetection: new Date(currIntervalEndTime).getTime(), - crossingLine: `LINESTRING (${prevLocation.location[0]} ${prevLocation.location[1]}, ${currLocation.location[0]} ${currLocation.location[1]})`, - - toEntityLocation: `POINT (${currLocation.location[0]} ${currLocation.location[1]})`, - toEntityDateTime: currLocation.date, - toEntityDocumentId: currLocation.docId, - - toBoundaryId: currLocation.shapeId, - toBoundaryName, - - fromEntityLocation: `POINT (${prevLocation.location[0]} ${prevLocation.location[1]})`, - fromEntityDateTime: prevLocation.date, - fromEntityDocumentId: prevLocation.docId, - - fromBoundaryId: prevLocation.shapeId, - fromBoundaryName, - }); - }); - - // Combine previous results w/ current results for state of next run - const prevLocationArr = _.chain(currLocationArr) - .concat(state.prevLocationArr) - .orderBy(['entityName', 'dateInShape'], ['asc', 'desc']) - .reduce((accu: LatestEntityLocation[], el: LatestEntityLocation) => { - if (!accu.length || el.entityName !== accu[accu.length - 1].entityName) { - accu.push(el); - } - return accu; - }, []) - .value(); - - return { - prevLocationArr, - shapesFilters, - shapesIdsNamesMap, - }; - }; diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/index.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/index.ts deleted file mode 100644 index 2fa2bed9d8419..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Logger } from 'src/core/server'; -import { AlertingSetup } from '../../types'; -import { getAlertType } from './alert_type'; - -interface RegisterParams { - logger: Logger; - alerts: AlertingSetup; -} - -export function register(params: RegisterParams) { - const { logger, alerts } = params; - alerts.registerType(getAlertType(logger)); -} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/__snapshots__/alert_type.test.ts.snap b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/__snapshots__/alert_type.test.ts.snap deleted file mode 100644 index 0cb04144fdb78..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/__snapshots__/alert_type.test.ts.snap +++ /dev/null @@ -1,60 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`alertType alert type creation structure is the expected value 1`] = ` -Object { - "context": Array [ - Object { - "description": "The entity ID of the document that triggered the alert", - "name": "entityId", - }, - Object { - "description": "The alert interval end time this change was recorded", - "name": "timeOfDetection", - }, - Object { - "description": "GeoJSON line connecting the two locations that were used to determine the crossing event", - "name": "crossingLine", - }, - Object { - "description": "The most recently captured location of the entity", - "name": "toEntityLocation", - }, - Object { - "description": "The time the entity was detected in the current boundary", - "name": "toEntityDateTime", - }, - Object { - "description": "The id of the crossing entity document", - "name": "toEntityDocumentId", - }, - Object { - "description": "The current boundary id containing the entity (if any)", - "name": "toBoundaryId", - }, - Object { - "description": "The boundary (if any) the entity has crossed into and is currently located", - "name": "toBoundaryName", - }, - Object { - "description": "The previously captured location of the entity", - "name": "fromEntityLocation", - }, - Object { - "description": "The last time the entity was recorded in the previous boundary", - "name": "fromEntityDateTime", - }, - Object { - "description": "The id of the crossing entity document", - "name": "fromEntityDocumentId", - }, - Object { - "description": "The previous boundary id containing the entity (if any)", - "name": "fromBoundaryId", - }, - Object { - "description": "The boundary (if any) the entity has crossed from and was previously located", - "name": "fromBoundaryName", - }, - ], -} -`; diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/alert_type.test.ts deleted file mode 100644 index 0cfce2d47f189..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/alert_type.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { loggingSystemMock } from '../../../../../../../src/core/server/mocks'; -import { getAlertType, GeoThresholdParams } from '../alert_type'; - -describe('alertType', () => { - const logger = loggingSystemMock.create().get(); - - const alertType = getAlertType(logger); - - it('alert type creation structure is the expected value', async () => { - expect(alertType.id).toBe('.geo-threshold'); - expect(alertType.name).toBe('Tracking threshold'); - expect(alertType.actionGroups).toEqual([ - { id: 'tracking threshold met', name: 'Tracking threshold met' }, - ]); - - expect(alertType.actionVariables).toMatchSnapshot(); - }); - - it('validator succeeds with valid params', async () => { - const params: GeoThresholdParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndex', - boundaryGeoField: 'testField', - boundaryNameField: 'testField', - delayOffsetWithUnits: 'testOffset', - }; - - expect(alertType.validate?.params?.validate(params)).toBeTruthy(); - }); - - it('validator fails with invalid params', async () => { - const paramsSchema = alertType.validate?.params; - if (!paramsSchema) throw new Error('params validator not set'); - - const params: GeoThresholdParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: '', - boundaryType: 'testType', - boundaryIndexTitle: '', - boundaryIndexId: 'testIndex', - boundaryGeoField: 'testField', - boundaryNameField: 'testField', - }; - - expect(() => paramsSchema.validate(params)).toThrowErrorMatchingInlineSnapshot( - `"[trackingEvent]: value has length [0] but it must have a minimum length of [1]."` - ); - }); -}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_query_builder.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_query_builder.test.ts deleted file mode 100644 index d577a88e8e2f8..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_query_builder.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { getEsFormattedQuery } from '../es_query_builder'; - -describe('esFormattedQuery', () => { - it('lucene queries are converted correctly', async () => { - const testLuceneQuery1 = { - query: `"airport": "Denver"`, - language: 'lucene', - }; - const esFormattedQuery1 = getEsFormattedQuery(testLuceneQuery1); - expect(esFormattedQuery1).toStrictEqual({ query_string: { query: '"airport": "Denver"' } }); - const testLuceneQuery2 = { - query: `title:"Fun with turnips" AND text:Cabbage, cabbage and more cabbage!`, - language: 'lucene', - }; - const esFormattedQuery2 = getEsFormattedQuery(testLuceneQuery2); - expect(esFormattedQuery2).toStrictEqual({ - query_string: { - query: `title:"Fun with turnips" AND text:Cabbage, cabbage and more cabbage!`, - }, - }); - }); - - it('kuery queries are converted correctly', async () => { - const testKueryQuery1 = { - query: `"airport": "Denver"`, - language: 'kuery', - }; - const esFormattedQuery1 = getEsFormattedQuery(testKueryQuery1); - expect(esFormattedQuery1).toStrictEqual({ - bool: { minimum_should_match: 1, should: [{ match_phrase: { airport: 'Denver' } }] }, - }); - const testKueryQuery2 = { - query: `"airport": "Denver" and ("animal": "goat" or "animal": "narwhal")`, - language: 'kuery', - }; - const esFormattedQuery2 = getEsFormattedQuery(testKueryQuery2); - expect(esFormattedQuery2).toStrictEqual({ - bool: { - filter: [ - { bool: { should: [{ match_phrase: { airport: 'Denver' } }], minimum_should_match: 1 } }, - { - bool: { - should: [ - { - bool: { should: [{ match_phrase: { animal: 'goat' } }], minimum_should_match: 1 }, - }, - { - bool: { - should: [{ match_phrase: { animal: 'narwhal' } }], - minimum_should_match: 1, - }, - }, - ], - minimum_should_match: 1, - }, - }, - ], - }, - }); - }); -}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response.json b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response.json deleted file mode 100644 index 70edbd09aa5a1..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response.json +++ /dev/null @@ -1,170 +0,0 @@ -{ - "took" : 2760, - "timed_out" : false, - "_shards" : { - "total" : 1, - "successful" : 1, - "skipped" : 0, - "failed" : 0 - }, - "hits" : { - "total" : { - "value" : 10000, - "relation" : "gte" - }, - "max_score" : 0.0, - "hits" : [] - }, - "aggregations" : { - "shapes" : { - "meta" : { }, - "buckets" : { - "0DrJu3QB6yyY-xQxv6Ip" : { - "doc_count" : 1047, - "entitySplit" : { - "doc_count_error_upper_bound" : 0, - "sum_other_doc_count" : 957, - "buckets" : [ - { - "key" : "936", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "N-ng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "@timestamp" : [ - "2020-09-28T18:01:41.190Z" - ], - "location" : [ - "40.62806099653244, -82.8814151789993" - ], - "entity_id" : [ - "936" - ] - }, - "sort" : [ - 1601316101190 - ] - } - ] - } - } - }, - { - "key" : "AAL2019", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "iOng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "@timestamp" : [ - "2020-09-28T18:01:41.191Z" - ], - "location" : [ - "39.006176185794175, -82.22068064846098" - ], - "entity_id" : [ - "AAL2019" - ] - }, - "sort" : [ - 1601316101191 - ] - } - ] - } - } - }, - { - "key" : "AAL2323", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "n-ng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "@timestamp" : [ - "2020-09-28T18:01:41.191Z" - ], - "location" : [ - "41.6677269525826, -84.71324851736426" - ], - "entity_id" : [ - "AAL2323" - ] - }, - "sort" : [ - 1601316101191 - ] - } - ] - } - } - }, - { - "key" : "ABD5250", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "GOng1XQB6yyY-xQxnGWM", - "_score" : null, - "fields" : { - "@timestamp" : [ - "2020-09-28T18:01:41.192Z" - ], - "location" : [ - "39.07997465226799, 6.073727197945118" - ], - "entity_id" : [ - "ABD5250" - ] - }, - "sort" : [ - 1601316101192 - ] - } - ] - } - } - } - ] - } - } - } - } - } -} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response_with_nesting.json b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response_with_nesting.json deleted file mode 100644 index a4b7b6872b341..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response_with_nesting.json +++ /dev/null @@ -1,170 +0,0 @@ -{ - "took" : 2760, - "timed_out" : false, - "_shards" : { - "total" : 1, - "successful" : 1, - "skipped" : 0, - "failed" : 0 - }, - "hits" : { - "total" : { - "value" : 10000, - "relation" : "gte" - }, - "max_score" : 0.0, - "hits" : [] - }, - "aggregations" : { - "shapes" : { - "meta" : { }, - "buckets" : { - "0DrJu3QB6yyY-xQxv6Ip" : { - "doc_count" : 1047, - "entitySplit" : { - "doc_count_error_upper_bound" : 0, - "sum_other_doc_count" : 957, - "buckets" : [ - { - "key" : "936", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "N-ng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "time_data.@timestamp" : [ - "2020-09-28T18:01:41.190Z" - ], - "geo.coords.location" : [ - "40.62806099653244, -82.8814151789993" - ], - "entity_id" : [ - "936" - ] - }, - "sort" : [ - 1601316101190 - ] - } - ] - } - } - }, - { - "key" : "AAL2019", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "iOng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "time_data.@timestamp" : [ - "2020-09-28T18:01:41.191Z" - ], - "geo.coords.location" : [ - "39.006176185794175, -82.22068064846098" - ], - "entity_id" : [ - "AAL2019" - ] - }, - "sort" : [ - 1601316101191 - ] - } - ] - } - } - }, - { - "key" : "AAL2323", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "n-ng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "time_data.@timestamp" : [ - "2020-09-28T18:01:41.191Z" - ], - "geo.coords.location" : [ - "41.6677269525826, -84.71324851736426" - ], - "entity_id" : [ - "AAL2323" - ] - }, - "sort" : [ - 1601316101191 - ] - } - ] - } - } - }, - { - "key" : "ABD5250", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "GOng1XQB6yyY-xQxnGWM", - "_score" : null, - "fields" : { - "time_data.@timestamp" : [ - "2020-09-28T18:01:41.192Z" - ], - "geo.coords.location" : [ - "39.07997465226799, 6.073727197945118" - ], - "entity_id" : [ - "ABD5250" - ] - }, - "sort" : [ - 1601316101192 - ] - } - ] - } - } - } - ] - } - } - } - } - } -} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/geo_threshold.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/geo_threshold.test.ts deleted file mode 100644 index 5b5197ac62a39..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/geo_threshold.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import sampleJsonResponse from './es_sample_response.json'; -import sampleJsonResponseWithNesting from './es_sample_response_with_nesting.json'; -import { getMovedEntities, transformResults } from '../geo_threshold'; -import { OTHER_CATEGORY } from '../es_query_builder'; -import { SearchResponse } from 'elasticsearch'; - -describe('geo_threshold', () => { - describe('transformResults', () => { - const dateField = '@timestamp'; - const geoField = 'location'; - it('should correctly transform expected results', async () => { - const transformedResults = transformResults( - (sampleJsonResponse as unknown) as SearchResponse, - dateField, - geoField - ); - expect(transformedResults).toEqual([ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'iOng1XQB6yyY-xQxnGSM', - entityName: 'AAL2019', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'n-ng1XQB6yyY-xQxnGSM', - entityName: 'AAL2323', - location: [-84.71324851736426, 41.6677269525826], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.192Z', - docId: 'GOng1XQB6yyY-xQxnGWM', - entityName: 'ABD5250', - location: [6.073727197945118, 39.07997465226799], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - ]); - }); - - const nestedDateField = 'time_data.@timestamp'; - const nestedGeoField = 'geo.coords.location'; - it('should correctly transform expected results if fields are nested', async () => { - const transformedResults = transformResults( - (sampleJsonResponseWithNesting as unknown) as SearchResponse, - nestedDateField, - nestedGeoField - ); - expect(transformedResults).toEqual([ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'iOng1XQB6yyY-xQxnGSM', - entityName: 'AAL2019', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'n-ng1XQB6yyY-xQxnGSM', - entityName: 'AAL2323', - location: [-84.71324851736426, 41.6677269525826], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.192Z', - docId: 'GOng1XQB6yyY-xQxnGWM', - entityName: 'ABD5250', - location: [6.073727197945118, 39.07997465226799], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - ]); - }); - - it('should return an empty array if no results', async () => { - const transformedResults = transformResults(undefined, dateField, geoField); - expect(transformedResults).toEqual([]); - }); - }); - - describe('getMovedEntities', () => { - it('should return empty array if only movements were within same shapes', async () => { - const currLocationArr = [ - { - dateInShape: '2020-08-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 41.62806099653244], - shapeLocationId: 'sameShape1', - }, - { - dateInShape: '2020-08-28T18:01:41.191Z', - docId: 'iOng1XQB6yyY-xQxnGSM', - entityName: 'AAL2019', - location: [-82.22068064846098, 38.006176185794175], - shapeLocationId: 'sameShape2', - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: 'sameShape1', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'iOng1XQB6yyY-xQxnGSM', - entityName: 'AAL2019', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: 'sameShape2', - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'entered'); - expect(movedEntities).toEqual([]); - }); - - it('should return result if entity has moved to different shape', async () => { - const currLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'currLocationDoc1', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: 'newShapeLocation', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'currLocationDoc2', - entityName: 'AAL2019', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: 'thisOneDidntMove', - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-09-27T18:01:41.190Z', - docId: 'prevLocationDoc1', - entityName: '936', - location: [-82.8814151789993, 20.62806099653244], - shapeLocationId: 'oldShapeLocation', - }, - { - dateInShape: '2020-09-27T18:01:41.191Z', - docId: 'prevLocationDoc2', - entityName: 'AAL2019', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: 'thisOneDidntMove', - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'entered'); - expect(movedEntities.length).toEqual(1); - }); - - it('should ignore "entered" results to "other"', async () => { - const currLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 41.62806099653244], - shapeLocationId: OTHER_CATEGORY, - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-08-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: 'oldShapeLocation', - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'entered'); - expect(movedEntities).toEqual([]); - }); - - it('should ignore "exited" results from "other"', async () => { - const currLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 41.62806099653244], - shapeLocationId: 'newShapeLocation', - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-08-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: OTHER_CATEGORY, - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'exited'); - expect(movedEntities).toEqual([]); - }); - - it('should not ignore "crossed" results from "other"', async () => { - const currLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 41.62806099653244], - shapeLocationId: 'newShapeLocation', - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-08-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: OTHER_CATEGORY, - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'crossed'); - expect(movedEntities.length).toEqual(1); - }); - - it('should not ignore "crossed" results to "other"', async () => { - const currLocationArr = [ - { - dateInShape: '2020-08-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: OTHER_CATEGORY, - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 41.62806099653244], - shapeLocationId: 'newShapeLocation', - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'crossed'); - expect(movedEntities.length).toEqual(1); - }); - }); -}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index.ts b/x-pack/plugins/stack_alerts/server/alert_types/index.ts index 2a343cb49a91b..5c35af5e344b9 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index.ts @@ -7,7 +7,6 @@ import { Logger } from 'src/core/server'; import { AlertingSetup, StackAlertsStartDeps } from '../types'; import { register as registerIndexThreshold } from './index_threshold'; -import { register as registerGeoThreshold } from './geo_threshold'; import { register as registerGeoContainment } from './geo_containment'; import { register as registerEsQuery } from './es_query'; interface RegisterAlertTypesParams { @@ -18,7 +17,6 @@ interface RegisterAlertTypesParams { export function registerBuiltInAlertTypes(params: RegisterAlertTypesParams) { registerIndexThreshold(params); - registerGeoThreshold(params); registerGeoContainment(params); registerEsQuery(params); } diff --git a/x-pack/plugins/stack_alerts/server/feature.ts b/x-pack/plugins/stack_alerts/server/feature.ts index 448e1e698858b..e334b4642a00a 100644 --- a/x-pack/plugins/stack_alerts/server/feature.ts +++ b/x-pack/plugins/stack_alerts/server/feature.ts @@ -6,7 +6,6 @@ import { i18n } from '@kbn/i18n'; import { ID as IndexThreshold } from './alert_types/index_threshold/alert_type'; -import { GEO_THRESHOLD_ID as GeoThreshold } from './alert_types/geo_threshold/alert_type'; import { GEO_CONTAINMENT_ID as GeoContainment } from './alert_types/geo_containment/alert_type'; import { STACK_ALERTS_FEATURE_ID } from '../common'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; @@ -21,7 +20,7 @@ export const BUILT_IN_ALERTS_FEATURE = { management: { insightsAndAlerting: ['triggersActions'], }, - alerting: [IndexThreshold, GeoThreshold, GeoContainment], + alerting: [IndexThreshold, GeoContainment], privileges: { all: { app: [], @@ -30,7 +29,7 @@ export const BUILT_IN_ALERTS_FEATURE = { insightsAndAlerting: ['triggersActions'], }, alerting: { - all: [IndexThreshold, GeoThreshold, GeoContainment], + all: [IndexThreshold, GeoContainment], read: [], }, savedObject: { @@ -48,7 +47,7 @@ export const BUILT_IN_ALERTS_FEATURE = { }, alerting: { all: [], - read: [IndexThreshold, GeoThreshold, GeoContainment], + read: [IndexThreshold, GeoContainment], }, savedObject: { all: [], diff --git a/x-pack/plugins/stack_alerts/server/plugin.test.ts b/x-pack/plugins/stack_alerts/server/plugin.test.ts index 8d69fad4afa46..0273f373734fa 100644 --- a/x-pack/plugins/stack_alerts/server/plugin.test.ts +++ b/x-pack/plugins/stack_alerts/server/plugin.test.ts @@ -27,7 +27,7 @@ describe('AlertingBuiltins Plugin', () => { const featuresSetup = featuresPluginMock.createSetup(); await plugin.setup(coreSetup, { alerts: alertingSetup, features: featuresSetup }); - expect(alertingSetup.registerType).toHaveBeenCalledTimes(4); + expect(alertingSetup.registerType).toHaveBeenCalledTimes(3); const indexThresholdArgs = alertingSetup.registerType.mock.calls[0][0]; const testedIndexThresholdArgs = { @@ -58,16 +58,16 @@ describe('AlertingBuiltins Plugin', () => { Object { "actionGroups": Array [ Object { - "id": "tracking threshold met", - "name": "Tracking threshold met", + "id": "Tracked entity contained", + "name": "Tracking containment met", }, ], - "id": ".geo-threshold", - "name": "Tracking threshold", + "id": ".geo-containment", + "name": "Tracking containment", } `); - const esQueryArgs = alertingSetup.registerType.mock.calls[3][0]; + const esQueryArgs = alertingSetup.registerType.mock.calls[2][0]; const testedEsQueryArgs = { id: esQueryArgs.id, name: esQueryArgs.name, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1c058245f04cd..4369cbf35594d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11188,7 +11188,6 @@ "xpack.lens.discover.visualizeFieldLegend": "Visualize フィールド", "xpack.lens.dragDrop.elementLifted": "位置 {position} のアイテム {itemLabel} を持ち上げました。", "xpack.lens.dragDrop.elementMoved": "位置 {prevPosition} から位置 {position} までアイテム {itemLabel} を移動しました", - "xpack.lens.dragDrop.reorderInstructions": "スペースバーを押すと、ドラッグを開始します。ドラッグするときには、矢印キーで並べ替えることができます。もう一度スペースバーを押すと終了します。", "xpack.lens.editLayerSettings": "レイヤー設定を編集", "xpack.lens.editLayerSettingsChartType": "レイヤー設定を編集、{chartType}", "xpack.lens.editorFrame.buildExpressionError": "グラフの準備中に予期しないエラーが発生しました", @@ -12595,7 +12594,6 @@ "xpack.ml.dataframe.analytics.create.duplicateIndexPatternErrorMessageError": "インデックスパターン{indexPatternName}はすでに作成されています。", "xpack.ml.dataframe.analytics.create.errorCheckingIndexExists": "既存のインデックス名の取得中に次のエラーが発生しました:{error}", "xpack.ml.dataframe.analytics.create.errorCreatingDataFrameAnalyticsJob": "データフレーム分析ジョブの作成中にエラーが発生しました。", - "xpack.ml.dataframe.analytics.create.errorGettingDataFrameAnalyticsList": "既存のデータフレーム分析ジョブIDの取得中にエラーが発生しました。", "xpack.ml.dataframe.analytics.create.errorGettingIndexPatternTitles": "既存のインデックスパターンのタイトルの取得中にエラーが発生しました。", "xpack.ml.dataframe.analytics.create.errorStartingDataFrameAnalyticsJob": "データフレーム分析ジョブの開始中にエラーが発生しました。", "xpack.ml.dataframe.analytics.create.etaInputAriaLabel": "縮小が重みに適用されました", @@ -14211,8 +14209,8 @@ "xpack.ml.splom.dynamicSizeLabel": "動的サイズ", "xpack.ml.splom.fieldSelectionLabel": "フィールド", "xpack.ml.splom.fieldSelectionPlaceholder": "フィールドを選択", - "xpack.ml.splom.RandomScoringLabel": "ランダムスコアリング", - "xpack.ml.splom.SampleSizeLabel": "サンプルサイズ", + "xpack.ml.splom.randomScoringLabel": "ランダムスコアリング", + "xpack.ml.splom.sampleSizeLabel": "サンプルサイズ", "xpack.ml.splom.toggleOff": "オフ", "xpack.ml.splom.toggleOn": "オン", "xpack.ml.splomSpec.outlierScoreThresholdName": "異常スコアしきい値:", @@ -17338,7 +17336,6 @@ "xpack.securitySolution.case.caseView.pushToServiceDisableByConfigDescription": "kibana.ymlファイルは、特定のコネクターのみを許可するように構成されています。外部システムでケースを開けるようにするには、xpack.actions.enabledActiontypes設定に.[actionTypeId](例:.servicenow | .jira)を追加します。詳細は{link}をご覧ください。", "xpack.securitySolution.case.caseView.pushToServiceDisableByConfigTitle": "Kibanaの構成ファイルで外部サービスを有効にする", "xpack.securitySolution.case.caseView.pushToServiceDisableByInvalidConnector": "外部サービスに更新を送信するために使用されるコネクターが削除されました。外部システムでケースを更新するには、別のコネクターを選択するか、新しいコネクターを作成してください。", - "xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseDescription": "外部システムでケースを開くには、ライセンスをプラチナに更新するか、30日間の無料トライアルを開始するか、AWS、GCP、またはAzureで{link}にサインアップする必要があります。", "xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseTitle": "E lastic Platinumへのアップグレード", "xpack.securitySolution.case.caseView.pushToServiceDisableByNoCaseConfigDescription": "外部システムでケースを開いて更新するには、このケースの外部インシデント管理システムを選択する必要があります。", "xpack.securitySolution.case.caseView.pushToServiceDisableByNoCaseConfigTitle": "外部コネクターを選択", @@ -20806,58 +20803,6 @@ "xpack.stackAlerts.geoContainment.timeFieldLabel": "時間フィールド", "xpack.stackAlerts.geoContainment.topHitsSplitFieldSelectPlaceholder": "エンティティフィールドを選択", "xpack.stackAlerts.geoContainment.ui.expressionPopover.closePopoverLabel": "閉じる", - "xpack.stackAlerts.geoThreshold.actionGroupThresholdMetTitle": "追跡しきい値が満たされました", - "xpack.stackAlerts.geoThreshold.actionVariableContextCrossingDocumentIdLabel": "クロスエンティティドキュメントのID", - "xpack.stackAlerts.geoThreshold.actionVariableContextCrossingLineLabel": "クロスイベントを決定するために使用された2つの場所を接続するGeoJSON行", - "xpack.stackAlerts.geoThreshold.actionVariableContextCurrentBoundaryIdLabel": "エンティティを含む現在の境界ID(該当する場合)", - "xpack.stackAlerts.geoThreshold.actionVariableContextEntityIdLabel": "アラートをトリガーしたドキュメントのエンティティ ID", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryIdLabel": "エンティティを含む以前の境界ID(該当する場合)", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryNameLabel": "エンティティがそこからクロスし、以前に検出された境界(該当する場合)", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDateTimeLabel": "前回エンティティが前の境界で記録された日時", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDocumentIdLabel": "クロスエンティティドキュメントのID", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityLocationLabel": "エンティティの以前に取り込まれた場所", - "xpack.stackAlerts.geoThreshold.actionVariableContextTimeOfDetectionLabel": "この変更が記録された、アラート間隔終了日時", - "xpack.stackAlerts.geoThreshold.actionVariableContextToBoundaryNameLabel": "エンティティがその中にクロスし、現在検出されている境界(該当する場合)", - "xpack.stackAlerts.geoThreshold.actionVariableContextToEntityDateTimeLabel": "現在の境界でエンティティが検出された日時", - "xpack.stackAlerts.geoThreshold.actionVariableContextToEntityLocationLabel": "エンティティの直近に取り込まれた場所", - "xpack.stackAlerts.geoThreshold.alertTypeTitle": "地理追跡しきい値", - "xpack.stackAlerts.geoThreshold.boundaryNameSelect": "境界名を選択", - "xpack.stackAlerts.geoThreshold.boundaryNameSelectLabel": "人間が読み取れる境界名(任意)", - "xpack.stackAlerts.geoThreshold.delayOffset": "遅延評価オフセット", - "xpack.stackAlerts.geoThreshold.delayOffsetTooltip": "遅延サイクルでアラートを評価し、データレイテンシに合わせて調整します", - "xpack.stackAlerts.geoThreshold.descriptionText": "エンティティが地理的境界に出入りするときにアラートを発行します。", - "xpack.stackAlerts.geoThreshold.entityByLabel": "グループ基準", - "xpack.stackAlerts.geoThreshold.entityIndexLabel": "インデックス", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryGeoFieldText": "境界地理フィールドは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryIndexTitleText": "境界インデックスパターンタイトルは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryTypeText": "境界タイプは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredDateFieldText": "日付フィールドが必要です。", - "xpack.stackAlerts.geoThreshold.error.requiredEntityText": "エンティティは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredGeoFieldText": "地理フィールドは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredIndexTitleText": "インデックスパターンが必要です。", - "xpack.stackAlerts.geoThreshold.error.requiredTrackingEventText": "追跡イベントは必須です。", - "xpack.stackAlerts.geoThreshold.fixErrorInExpressionBelowValidationMessage": "下の表現のエラーを修正してください。", - "xpack.stackAlerts.geoThreshold.geofieldLabel": "地理空間フィールド", - "xpack.stackAlerts.geoThreshold.indexLabel": "インデックス", - "xpack.stackAlerts.geoThreshold.indexPatternSelectLabel": "インデックスパターン", - "xpack.stackAlerts.geoThreshold.indexPatternSelectPlaceholder": "インデックスパターンを選択", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisLinkTextDescription": "インデックスパターンを作成します", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisPrefixDescription": "次のことが必要です ", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisSuffixDescription": " 地理空間フィールドを含む", - "xpack.stackAlerts.geoThreshold.noIndexPattern.getStartedLinkText": "サンプルデータセットで始めましょう。", - "xpack.stackAlerts.geoThreshold.noIndexPattern.hintDescription": "地理空間データセットがありませんか?", - "xpack.stackAlerts.geoThreshold.noIndexPattern.messageTitle": "地理空間フィールドを含むインデックスパターンが見つかりませんでした", - "xpack.stackAlerts.geoThreshold.selectBoundaryIndex": "境界を選択:", - "xpack.stackAlerts.geoThreshold.selectEntity": "エンティティを選択", - "xpack.stackAlerts.geoThreshold.selectGeoLabel": "ジオフィールドを選択", - "xpack.stackAlerts.geoThreshold.selectIndex": "条件を定義してください", - "xpack.stackAlerts.geoThreshold.selectLabel": "ジオフィールドを選択", - "xpack.stackAlerts.geoThreshold.selectOffset": "オフセットを選択(任意)", - "xpack.stackAlerts.geoThreshold.selectTimeLabel": "時刻フィールドを選択", - "xpack.stackAlerts.geoThreshold.timeFieldLabel": "時間フィールド", - "xpack.stackAlerts.geoThreshold.topHitsSplitFieldSelectPlaceholder": "エンティティフィールドを選択", - "xpack.stackAlerts.geoThreshold.ui.expressionPopover.closePopoverLabel": "閉じる", - "xpack.stackAlerts.geoThreshold.whenEntityLabel": "エンティティ", "xpack.stackAlerts.indexThreshold.actionGroupThresholdMetTitle": "しきい値一致", "xpack.stackAlerts.indexThreshold.actionVariableContextConditionsLabel": "しきい値比較基準としきい値を説明する文字列", "xpack.stackAlerts.indexThreshold.actionVariableContextDateLabel": "アラートがしきい値を超えた日付。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e7dbc0c161a37..d2504c8752c05 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11217,7 +11217,6 @@ "xpack.lens.discover.visualizeFieldLegend": "可视化字段", "xpack.lens.dragDrop.elementLifted": "您已将项目 {itemLabel} 提升到位置 {position}", "xpack.lens.dragDrop.elementMoved": "您已将项目 {itemLabel} 从位置 {prevPosition} 移到位置 {position}", - "xpack.lens.dragDrop.reorderInstructions": "按空格键开始拖动。拖动时,使用方向键重新排序。再次按空格键结束操作。", "xpack.lens.editLayerSettings": "编辑图层设置", "xpack.lens.editLayerSettingsChartType": "编辑图层设置 {chartType}", "xpack.lens.editorFrame.buildExpressionError": "准备图表时发生意外错误", @@ -12624,7 +12623,6 @@ "xpack.ml.dataframe.analytics.create.duplicateIndexPatternErrorMessageError": "索引模式 {indexPatternName} 已存在。", "xpack.ml.dataframe.analytics.create.errorCheckingIndexExists": "获取现有索引名称时发生以下错误:{error}", "xpack.ml.dataframe.analytics.create.errorCreatingDataFrameAnalyticsJob": "创建数据帧分析作业时发生错误:", - "xpack.ml.dataframe.analytics.create.errorGettingDataFrameAnalyticsList": "获取现有数据帧分析作业 ID 时发生错误:", "xpack.ml.dataframe.analytics.create.errorGettingIndexPatternTitles": "获取现有索引模式标题时发生错误:", "xpack.ml.dataframe.analytics.create.errorStartingDataFrameAnalyticsJob": "启动数据帧分析作业时发生错误:", "xpack.ml.dataframe.analytics.create.etaInputAriaLabel": "缩小量已应用于权重", @@ -14250,8 +14248,8 @@ "xpack.ml.splom.dynamicSizeLabel": "动态大小", "xpack.ml.splom.fieldSelectionLabel": "字段", "xpack.ml.splom.fieldSelectionPlaceholder": "选择字段", - "xpack.ml.splom.RandomScoringLabel": "随机评分", - "xpack.ml.splom.SampleSizeLabel": "样例大小", + "xpack.ml.splom.randomScoringLabel": "随机评分", + "xpack.ml.splom.sampleSizeLabel": "样例大小", "xpack.ml.splom.toggleOff": "关闭", "xpack.ml.splom.toggleOn": "开启", "xpack.ml.splomSpec.outlierScoreThresholdName": "离群值分数阈值:", @@ -17382,7 +17380,6 @@ "xpack.securitySolution.case.caseView.pushToServiceDisableByConfigDescription": "kibana.yml 文件已配置为仅允许特定连接器。要在外部系统中打开案例,请将 .[actionTypeId](例如:.servicenow | .jira)添加到 xpack.actions.enabledActiontypes 设置。有关更多信息,请参阅{link}。", "xpack.securitySolution.case.caseView.pushToServiceDisableByConfigTitle": "在 Kibana 配置文件中启用外部服务", "xpack.securitySolution.case.caseView.pushToServiceDisableByInvalidConnector": "用于将更新发送到外部服务的连接器已删除。要在外部系统中更新案例,请选择不同的连接器或创建新的连接器。", - "xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseDescription": "要在外部系统中打开案例,必须将许可证更新到白金级,开始为期 30 天的免费试用,或在 AWS、GCP 或 Azure 上快速部署 {link}。", "xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseTitle": "升级到 Elastic 白金级", "xpack.securitySolution.case.caseView.pushToServiceDisableByNoCaseConfigDescription": "要在外部系统中打开和更新案例,必须为此案例选择外部事件管理系统。", "xpack.securitySolution.case.caseView.pushToServiceDisableByNoCaseConfigTitle": "选择外部连接器", @@ -20854,58 +20851,6 @@ "xpack.stackAlerts.geoContainment.timeFieldLabel": "时间字段", "xpack.stackAlerts.geoContainment.topHitsSplitFieldSelectPlaceholder": "选择实体字段", "xpack.stackAlerts.geoContainment.ui.expressionPopover.closePopoverLabel": "关闭", - "xpack.stackAlerts.geoThreshold.actionGroupThresholdMetTitle": "已达到跟踪阈值", - "xpack.stackAlerts.geoThreshold.actionVariableContextCrossingDocumentIdLabel": "穿越实体文档的 ID", - "xpack.stackAlerts.geoThreshold.actionVariableContextCrossingLineLabel": "连接用于确定穿越事件的两个位置的 GeoJSON 线", - "xpack.stackAlerts.geoThreshold.actionVariableContextCurrentBoundaryIdLabel": "包含实体的当前边界 ID(如果有)", - "xpack.stackAlerts.geoThreshold.actionVariableContextEntityIdLabel": "触发了告警的文档的实体 ID", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryIdLabel": "包含实体的上一边界 ID(如果有)", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryNameLabel": "实体从中穿越出且先前所位于的边界(如果有)", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDateTimeLabel": "实体上次在上一边界中记录的时间", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDocumentIdLabel": "穿越实体文档的 ID", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityLocationLabel": "实体的先前捕获位置", - "xpack.stackAlerts.geoThreshold.actionVariableContextTimeOfDetectionLabel": "记录此更改的告警时间间隔结束时间", - "xpack.stackAlerts.geoThreshold.actionVariableContextToBoundaryNameLabel": "实体已穿越进且当前位于的边界(如果有)", - "xpack.stackAlerts.geoThreshold.actionVariableContextToEntityDateTimeLabel": "在当前边界中检测到实体的时间", - "xpack.stackAlerts.geoThreshold.actionVariableContextToEntityLocationLabel": "实体的最近捕获位置", - "xpack.stackAlerts.geoThreshold.alertTypeTitle": "地理跟踪阈值", - "xpack.stackAlerts.geoThreshold.boundaryNameSelect": "选择边界名称", - "xpack.stackAlerts.geoThreshold.boundaryNameSelectLabel": "可人工读取的边界名称(可选)", - "xpack.stackAlerts.geoThreshold.delayOffset": "已延迟的评估偏移", - "xpack.stackAlerts.geoThreshold.delayOffsetTooltip": "评估延迟周期内的告警,以针对数据延迟进行调整", - "xpack.stackAlerts.geoThreshold.descriptionText": "实体进入或离开地理边界时告警。", - "xpack.stackAlerts.geoThreshold.entityByLabel": "依据", - "xpack.stackAlerts.geoThreshold.entityIndexLabel": "索引", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryGeoFieldText": "“边界地理”字段必填。", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryIndexTitleText": "“边界索引模式标题”必填。", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryTypeText": "“边界类型”必填。", - "xpack.stackAlerts.geoThreshold.error.requiredDateFieldText": "“日期”字段必填。", - "xpack.stackAlerts.geoThreshold.error.requiredEntityText": "“实体”必填。", - "xpack.stackAlerts.geoThreshold.error.requiredGeoFieldText": "“地理”字段必填。", - "xpack.stackAlerts.geoThreshold.error.requiredIndexTitleText": "“索引模式”必填。", - "xpack.stackAlerts.geoThreshold.error.requiredTrackingEventText": "“跟踪事件”必填。", - "xpack.stackAlerts.geoThreshold.fixErrorInExpressionBelowValidationMessage": "表达式包含错误。", - "xpack.stackAlerts.geoThreshold.geofieldLabel": "地理空间字段", - "xpack.stackAlerts.geoThreshold.indexLabel": "索引", - "xpack.stackAlerts.geoThreshold.indexPatternSelectLabel": "索引模式", - "xpack.stackAlerts.geoThreshold.indexPatternSelectPlaceholder": "选择索引模式", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisLinkTextDescription": "创建索引模式", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisPrefixDescription": "您将需要 ", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisSuffixDescription": " (包含地理空间字段)。", - "xpack.stackAlerts.geoThreshold.noIndexPattern.getStartedLinkText": "开始使用一些样例数据集。", - "xpack.stackAlerts.geoThreshold.noIndexPattern.hintDescription": "没有任何地理空间数据集?", - "xpack.stackAlerts.geoThreshold.noIndexPattern.messageTitle": "找不到任何具有地理空间字段的索引模式", - "xpack.stackAlerts.geoThreshold.selectBoundaryIndex": "选择边界:", - "xpack.stackAlerts.geoThreshold.selectEntity": "选择实体", - "xpack.stackAlerts.geoThreshold.selectGeoLabel": "选择地理字段", - "xpack.stackAlerts.geoThreshold.selectIndex": "定义条件", - "xpack.stackAlerts.geoThreshold.selectLabel": "选择地理字段", - "xpack.stackAlerts.geoThreshold.selectOffset": "选择偏移(可选)", - "xpack.stackAlerts.geoThreshold.selectTimeLabel": "选择时间字段", - "xpack.stackAlerts.geoThreshold.timeFieldLabel": "时间字段", - "xpack.stackAlerts.geoThreshold.topHitsSplitFieldSelectPlaceholder": "选择实体字段", - "xpack.stackAlerts.geoThreshold.ui.expressionPopover.closePopoverLabel": "关闭", - "xpack.stackAlerts.geoThreshold.whenEntityLabel": "当实体", "xpack.stackAlerts.indexThreshold.actionGroupThresholdMetTitle": "已达到阈值", "xpack.stackAlerts.indexThreshold.actionVariableContextConditionsLabel": "描述阈值比较运算符和阈值的字符串", "xpack.stackAlerts.indexThreshold.actionVariableContextDateLabel": "告警超过阈值的日期。", diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.test.tsx index 1c9a079bcf1eb..772d558a0d20d 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.test.tsx @@ -5,9 +5,9 @@ */ import React from 'react'; -import { shallowWithIntl } from '@kbn/test/jest'; +import { shallow } from 'enzyme'; -import { IndexDeprecationTableProps, IndexDeprecationTableUI } from './index_table'; +import { IndexDeprecationTableProps, IndexDeprecationTable } from './index_table'; describe('IndexDeprecationTable', () => { const defaultProps = { @@ -22,7 +22,7 @@ describe('IndexDeprecationTable', () => { // This test simply verifies that the props passed to EuiBaseTable are the ones // expected. test('render', () => { - expect(shallowWithIntl()).toMatchInlineSnapshot(` + expect(shallow()).toMatchInlineSnapshot(` { @@ -49,24 +49,27 @@ export class IndexDeprecationTableUI extends React.Component< } public render() { - const { intl } = this.props; const { pageIndex, pageSize, sortField, sortDirection } = this.state; const columns = [ { field: 'index', - name: intl.formatMessage({ - id: 'xpack.upgradeAssistant.checkupTab.deprecations.indexTable.indexColumnLabel', - defaultMessage: 'Index', - }), + name: i18n.translate( + 'xpack.upgradeAssistant.checkupTab.deprecations.indexTable.indexColumnLabel', + { + defaultMessage: 'Index', + } + ), sortable: true, }, { field: 'details', - name: intl.formatMessage({ - id: 'xpack.upgradeAssistant.checkupTab.deprecations.indexTable.detailsColumnLabel', - defaultMessage: 'Details', - }), + name: i18n.translate( + 'xpack.upgradeAssistant.checkupTab.deprecations.indexTable.detailsColumnLabel', + { + defaultMessage: 'Details', + } + ), }, ]; @@ -169,5 +172,3 @@ export class IndexDeprecationTableUI extends React.Component< }; } } - -export const IndexDeprecationTable = injectI18n(IndexDeprecationTableUI); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/deprecation_logging_toggle.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/deprecation_logging_toggle.tsx index 0e6c79dc47b53..7a1ffb955db5c 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/deprecation_logging_toggle.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/deprecation_logging_toggle.tsx @@ -7,13 +7,13 @@ import React from 'react'; import { EuiLoadingSpinner, EuiSwitch } from '@elastic/eui'; -import { injectI18n } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { HttpSetup } from 'src/core/public'; import { LoadingState } from '../../types'; -interface DeprecationLoggingTabProps extends ReactIntl.InjectedIntlProps { +interface DeprecationLoggingTabProps { http: HttpSetup; } @@ -22,7 +22,7 @@ interface DeprecationLoggingTabState { loggingEnabled?: boolean; } -export class DeprecationLoggingToggleUI extends React.Component< +export class DeprecationLoggingToggle extends React.Component< DeprecationLoggingTabProps, DeprecationLoggingTabState > { @@ -59,27 +59,29 @@ export class DeprecationLoggingToggleUI extends React.Component< } private renderLoggingState() { - const { intl } = this.props; const { loggingEnabled, loadingState } = this.state; if (loadingState === LoadingState.Error) { - return intl.formatMessage({ - id: - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.errorLabel', - defaultMessage: 'Could not load logging state', - }); + return i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.errorLabel', + { + defaultMessage: 'Could not load logging state', + } + ); } else if (loggingEnabled) { - return intl.formatMessage({ - id: - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.enabledLabel', - defaultMessage: 'On', - }); + return i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.enabledLabel', + { + defaultMessage: 'On', + } + ); } else { - return intl.formatMessage({ - id: - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.disabledLabel', - defaultMessage: 'Off', - }); + return i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.disabledLabel', + { + defaultMessage: 'Off', + } + ); } } @@ -117,5 +119,3 @@ export class DeprecationLoggingToggleUI extends React.Component< } }; } - -export const DeprecationLoggingToggle = injectI18n(DeprecationLoggingToggleUI); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/steps.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/steps.tsx index 1a1ea48a350c8..dd392f6d1b294 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/steps.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/steps.tsx @@ -17,7 +17,7 @@ import { EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { CURRENT_MAJOR_VERSION, NEXT_MAJOR_VERSION } from '../../../../../common/version'; import { UpgradeAssistantTabProps } from '../../types'; @@ -89,10 +89,9 @@ const START_UPGRADE_STEP = (isCloudEnabled: boolean, esDocBasePath: string) => ( ), }); -export const StepsUI: FunctionComponent = ({ +export const Steps: FunctionComponent = ({ checkupData, setSelectedTabIndex, - intl, }) => { const checkupDataTyped = (checkupData! as unknown) as { [checkupType: string]: any[] }; const countByType = Object.keys(checkupDataTyped).reduce((counts, checkupType) => { @@ -113,15 +112,18 @@ export const StepsUI: FunctionComponent @@ -168,15 +170,18 @@ export const StepsUI: FunctionComponent @@ -222,10 +227,12 @@ export const StepsUI: FunctionComponent @@ -256,11 +263,12 @@ export const StepsUI: FunctionComponent @@ -276,5 +284,3 @@ export const StepsUI: FunctionComponent ); }; - -export const Steps = injectI18n(StepsUI); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx index 28b74c5affbdf..b3d20a6acd3e3 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx @@ -59,10 +59,13 @@ describe('useBarChartsHooks', () => { const firstChartItems = result.current[0]; const lastChartItems = result.current[4]; - // first chart items last item should be x 199, since we only display 150 items + // first chart items last item should be x 149, since we only display 150 items expect(firstChartItems[firstChartItems.length - 1].x).toBe(CANVAS_MAX_ITEMS - 1); - // since here are 5 charts, last chart first item should be x 800 + // first chart will only contain x values from 0 - 149; + expect(firstChartItems.find((item) => item.x > 149)).toBe(undefined); + + // since here are 5 charts, last chart first item should be x 600 expect(lastChartItems[0].x).toBe(CANVAS_MAX_ITEMS * 4); expect(lastChartItems[lastChartItems.length - 1].x).toBe(CANVAS_MAX_ITEMS * 5 - 1); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts index 3345b30f5239f..7beb0be28902b 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts @@ -17,17 +17,16 @@ export const useBarCharts = ({ data = [] }: UseBarHookProps) => { useEffect(() => { if (data.length > 0) { - let chartIndex = 1; + let chartIndex = 0; - const firstCanvasItems = data.filter((item) => item.x <= CANVAS_MAX_ITEMS); - - const chartsN: Array = [firstCanvasItems]; + const chartsN: Array = []; data.forEach((item) => { // Subtract 1 to account for x value starting from 0 if (item.x === CANVAS_MAX_ITEMS * chartIndex && !chartsN[item.x / CANVAS_MAX_ITEMS]) { - chartsN.push([]); + chartsN.push([item]); chartIndex++; + return; } chartsN[chartIndex - 1].push(item); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts index 8ed979a171169..2a256266697e6 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts @@ -11,10 +11,6 @@ import { setupSpacesAndUsers, tearDown } from '..'; export default function alertingTests({ loadTestFile, getService }: FtrProviderContext) { describe('Alerts', () => { describe('legacy alerts', () => { - before(async () => { - await setupSpacesAndUsers(getService); - }); - after(async () => { await tearDown(getService); }); diff --git a/x-pack/test/api_integration/apis/search/search.ts b/x-pack/test/api_integration/apis/search/search.ts index 0c08b834a2778..2115976bcced1 100644 --- a/x-pack/test/api_integration/apis/search/search.ts +++ b/x-pack/test/api_integration/apis/search/search.ts @@ -6,6 +6,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { verifyErrorResponse } from '../../../../../test/api_integration/apis/search/verify_error'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -90,6 +91,23 @@ export default function ({ getService }: FtrProviderContext) { expect(resp2.body.isRunning).to.be(false); }); + it('should fail without kbn-xref header', async () => { + const resp = await supertest + .post(`/internal/search/ese`) + .send({ + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }) + .expect(400); + + verifyErrorResponse(resp.body, 400, 'Request must contain a kbn-xsrf header.'); + }); + it('should return 400 when unknown index type is provided', async () => { const resp = await supertest .post(`/internal/search/ese`) @@ -106,7 +124,7 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(400); - expect(resp.body.message).to.contain('Unknown indexType'); + verifyErrorResponse(resp.body, 400, 'Unknown indexType'); }); it('should return 400 if invalid id is provided', async () => { @@ -124,7 +142,7 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(400); - expect(resp.body.message).to.contain('illegal_argument_exception'); + verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true); }); it('should return 404 if unkown id is provided', async () => { @@ -143,12 +161,11 @@ export default function ({ getService }: FtrProviderContext) { }, }) .expect(404); - - expect(resp.body.message).to.contain('resource_not_found_exception'); + verifyErrorResponse(resp.body, 404, 'resource_not_found_exception', true); }); it('should return 400 with a bad body', async () => { - await supertest + const resp = await supertest .post(`/internal/search/ese`) .set('kbn-xsrf', 'foo') .send({ @@ -160,6 +177,8 @@ export default function ({ getService }: FtrProviderContext) { }, }) .expect(400); + + verifyErrorResponse(resp.body, 400, 'parsing_exception', true); }); }); @@ -186,8 +205,7 @@ export default function ({ getService }: FtrProviderContext) { }, }) .expect(400); - - expect(resp.body.message).to.contain('illegal_argument_exception'); + verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true); }); it('should return 400 if rollup search is without non-existent index', async () => { @@ -207,7 +225,7 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(400); - expect(resp.body.message).to.contain('illegal_argument_exception'); + verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true); }); it('should rollup search', async () => { @@ -241,7 +259,7 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send() .expect(400); - expect(resp.body.message).to.contain('illegal_argument_exception'); + verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true); }); it('should delete a search', async () => { diff --git a/x-pack/test/functional/apps/canvas/index.js b/x-pack/test/functional/apps/canvas/index.js index b7031cf0e55da..d5f7540f48c83 100644 --- a/x-pack/test/functional/apps/canvas/index.js +++ b/x-pack/test/functional/apps/canvas/index.js @@ -26,6 +26,7 @@ export default function canvasApp({ loadTestFile, getService }) { loadTestFile(require.resolve('./custom_elements')); loadTestFile(require.resolve('./feature_controls/canvas_security')); loadTestFile(require.resolve('./feature_controls/canvas_spaces')); + loadTestFile(require.resolve('./lens')); loadTestFile(require.resolve('./reports')); }); } diff --git a/x-pack/test/functional/apps/canvas/lens.ts b/x-pack/test/functional/apps/canvas/lens.ts new file mode 100644 index 0000000000000..e74795de6c7ea --- /dev/null +++ b/x-pack/test/functional/apps/canvas/lens.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function canvasLensTest({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['canvas', 'common', 'header', 'lens']); + const esArchiver = getService('esArchiver'); + + describe('lens in canvas', function () { + before(async () => { + await esArchiver.load('canvas/lens'); + // open canvas home + await PageObjects.common.navigateToApp('canvas'); + // load test workpad + await PageObjects.common.navigateToApp('canvas', { + hash: '/workpad/workpad-1705f884-6224-47de-ba49-ca224fe6ec31/page/1', + }); + }); + + it('renders lens visualization', async () => { + await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.lens.assertMetric('Maximum of bytes', '16,788'); + }); + }); +} diff --git a/x-pack/test/functional/apps/dashboard/_async_dashboard.ts b/x-pack/test/functional/apps/dashboard/_async_dashboard.ts index 8851c83dea1ff..fceccb4609bd7 100644 --- a/x-pack/test/functional/apps/dashboard/_async_dashboard.ts +++ b/x-pack/test/functional/apps/dashboard/_async_dashboard.ts @@ -12,6 +12,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const browser = getService('browser'); const kibanaServer = getService('kibanaServer'); + const esArchiver = getService('esArchiver'); const log = getService('log'); const pieChart = getService('pieChart'); const find = getService('find'); @@ -29,6 +30,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('sample data dashboard', function describeIndexTests() { before(async () => { + await esArchiver.emptyKibanaIndex(); await PageObjects.common.sleep(5000); await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { useActualUrl: true, diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts index 6b42306c08c92..b0f1e316e626a 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts @@ -40,6 +40,17 @@ export default function ({ getService }: FtrProviderContext) { modelMemory: '60mb', createIndexPattern: true, expected: { + scatterplotMatrixColorStats: [ + // background + { key: '#000000', value: 94 }, + // tick/grid/axis + { key: '#DDDDDD', value: 1 }, + { key: '#D3DAE6', value: 1 }, + { key: '#F5F7FA', value: 1 }, + // scatterplot circles + { key: '#6A717D', value: 1 }, + { key: '#54B39A', value: 1 }, + ], row: { type: 'classification', status: 'stopped', @@ -89,6 +100,12 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('displays the include fields selection'); await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); + await ml.testExecution.logTestStep('displays the scatterplot matrix'); + await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + 'mlAnalyticsCreateJobWizardScatterplotMatrixFormRow', + testData.expected.scatterplotMatrixColorStats + ); + await ml.testExecution.logTestStep('continues to the additional options step'); await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); @@ -207,6 +224,10 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsResults.assertResultsTableExists(); await ml.dataFrameAnalyticsResults.assertResultsTableTrainingFiltersExist(); await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); + await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + 'mlDFExpandableSection-splom', + testData.expected.scatterplotMatrixColorStats + ); }); it('displays the analytics job in the map view', async () => { diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts index 53daa0cae2522..419239d1d15ca 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts @@ -49,6 +49,27 @@ export default function ({ getService }: FtrProviderContext) { { chartAvailable: true, id: 'Exterior2nd', legend: '3 categories' }, { chartAvailable: true, id: 'Fireplaces', legend: '0 - 3' }, ], + scatterplotMatrixColorStatsWizard: [ + // background + { key: '#000000', value: 91 }, + // tick/grid/axis + { key: '#6A717D', value: 2 }, + { key: '#F5F7FA', value: 2 }, + { key: '#D3DAE6', value: 1 }, + // scatterplot circles + { key: '#54B399', value: 1 }, + { key: '#54B39A', value: 1 }, + ], + scatterplotMatrixColorStatsResults: [ + // background + { key: '#000000', value: 91 }, + // tick/grid/axis, grey markers + // the red outlier color is not above the 1% threshold. + { key: '#6A717D', value: 2 }, + { key: '#98A2B3', value: 1 }, + { key: '#F5F7FA', value: 2 }, + { key: '#D3DAE6', value: 1 }, + ], row: { type: 'outlier_detection', status: 'stopped', @@ -105,6 +126,12 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('displays the include fields selection'); await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); + await ml.testExecution.logTestStep('displays the scatterplot matrix'); + await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + 'mlAnalyticsCreateJobWizardScatterplotMatrixFormRow', + testData.expected.scatterplotMatrixColorStatsWizard + ); + await ml.testExecution.logTestStep('continues to the additional options step'); await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); @@ -221,6 +248,10 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsResults.assertOutlierTablePanelExists(); await ml.dataFrameAnalyticsResults.assertResultsTableExists(); await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); + await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + 'mlDFExpandableSection-splom', + testData.expected.scatterplotMatrixColorStatsResults + ); }); it('displays the analytics job in the map view', async () => { diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts index fef22fcebc3ed..f1d19a82caa9b 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts @@ -39,6 +39,16 @@ export default function ({ getService }: FtrProviderContext) { modelMemory: '20mb', createIndexPattern: true, expected: { + scatterplotMatrixColorStats: [ + // background + { key: '#000000', value: 80 }, + // tick/grid/axis + { key: '#6A717D', value: 1 }, + { key: '#F5F7FA', value: 2 }, + { key: '#D3DAE6', value: 1 }, + // because a continuous color scale is used for the scatterplot circles, + // none of the generated colors is above the 1% threshold. + ], row: { type: 'regression', status: 'stopped', @@ -89,6 +99,12 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('displays the include fields selection'); await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); + await ml.testExecution.logTestStep('displays the scatterplot matrix'); + await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + 'mlAnalyticsCreateJobWizardScatterplotMatrixFormRow', + testData.expected.scatterplotMatrixColorStats + ); + await ml.testExecution.logTestStep('continues to the additional options step'); await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); @@ -207,6 +223,10 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsResults.assertResultsTableExists(); await ml.dataFrameAnalyticsResults.assertResultsTableTrainingFiltersExist(); await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); + await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + 'mlDFExpandableSection-splom', + testData.expected.scatterplotMatrixColorStats + ); }); it('displays the analytics job in the map view', async () => { diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 1815942a06a9a..fc508f8477ebe 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -232,6 +232,7 @@ export default async function ({ readConfigFile }) { { feature: { canvas: ['all'], + visualize: ['all'], }, spaces: ['*'], }, diff --git a/x-pack/test/functional/es_archives/canvas/lens/data.json b/x-pack/test/functional/es_archives/canvas/lens/data.json new file mode 100644 index 0000000000000..dca7d31d71082 --- /dev/null +++ b/x-pack/test/functional/es_archives/canvas/lens/data.json @@ -0,0 +1,190 @@ +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana_1", + "source": { + "space": { + "_reserved": true, + "color": "#00bfb3", + "description": "This is your default space!", + "name": "Default" + }, + "type": "space", + "updated_at": "2018-11-06T18:20:26.703Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "canvas-workpad:workpad-1705f884-6224-47de-ba49-ca224fe6ec31", + "index": ".kibana_1", + "source": { + "canvas-workpad": { + "@created": "2018-11-19T19:17:12.646Z", + "@timestamp": "2018-11-19T19:36:28.499Z", + "assets": { + }, + "colors": [ + "#37988d", + "#c19628", + "#b83c6f", + "#3f9939", + "#1785b0", + "#ca5f35", + "#45bdb0", + "#f2bc33", + "#e74b8b", + "#4fbf48", + "#1ea6dc", + "#fd7643", + "#72cec3", + "#f5cc5d", + "#ec77a8", + "#7acf74", + "#4cbce4", + "#fd986f", + "#a1ded7", + "#f8dd91", + "#f2a4c5", + "#a6dfa2", + "#86d2ed", + "#fdba9f", + "#000000", + "#444444", + "#777777", + "#BBBBBB", + "#FFFFFF", + "rgba(255,255,255,0)" + ], + "height": 920, + "id": "workpad-1705f884-6224-47de-ba49-ca224fe6ec31", + "isWriteable": true, + "name": "Test Workpad", + "page": 0, + "pages": [ + { + "elements": [ + { + "expression": "savedLens id=\"my-lens-vis\" timerange={timerange from=\"2014-01-01\" to=\"2018-01-01\"}", + "id": "element-8f64a10a-01f3-4a71-a682-5b627cbe4d0e", + "position": { + "angle": 0, + "height": 238, + "left": 33.5, + "top": 20, + "width": 338 + } + } + ], + "id": "page-c38cd459-10fe-45f9-847b-2cbd7ec74319", + "style": { + "background": "#fff" + }, + "transition": { + } + } + ], + "width": 840 + }, + "type": "canvas-workpad", + "updated_at": "2018-11-19T19:36:28.511Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "lens:my-lens-vis", + "index": ".kibana_1", + "source": { + "lens": { + "expression": "", + "state": { + "datasourceMetaData": { + "filterableIndexPatterns": [ + { + "id": "logstash-lens", + "title": "logstash-lens" + } + ] + }, + "datasourceStates": { + "indexpattern": { + "currentIndexPatternId": "logstash-lens", + "layers": { + "c61a8afb-a185-4fae-a064-fb3846f6c451": { + "columnOrder": [ + "2cd09808-3915-49f4-b3b0-82767eba23f7" + ], + "columns": { + "2cd09808-3915-49f4-b3b0-82767eba23f7": { + "dataType": "number", + "isBucketed": false, + "label": "Maximum of bytes", + "operationType": "max", + "scale": "ratio", + "sourceField": "bytes" + } + }, + "indexPatternId": "logstash-lens" + } + } + } + }, + "filters": [], + "query": { + "language": "kuery", + "query": "" + }, + "visualization": { + "accessor": "2cd09808-3915-49f4-b3b0-82767eba23f7", + "layerId": "c61a8afb-a185-4fae-a064-fb3846f6c451" + } + }, + "title": "Artistpreviouslyknownaslens", + "visualizationType": "lnsMetric" + }, + "references": [], + "type": "lens", + "updated_at": "2019-10-16T00:28:08.979Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": "logstash-lens", + "id": "1", + "source": { + "@timestamp": "2015-09-20T02:00:00.000Z", + "bytes": 16788 + } + } +} + +{ + "type": "doc", + "value": { + "id": "index-pattern:logstash-lens", + "index": ".kibana_1", + "source": { + "index-pattern" : { + "title" : "logstash-lens", + "timeFieldName" : "@timestamp", + "fields" : "[]" + }, + "type" : "index-pattern", + "references" : [ ], + "migrationVersion" : { + "index-pattern" : "7.6.0" + }, + "updated_at" : "2020-08-19T08:39:09.998Z" + }, + "type": "_doc" + } +} \ No newline at end of file diff --git a/x-pack/test/functional/es_archives/canvas/lens/mappings.json b/x-pack/test/functional/es_archives/canvas/lens/mappings.json new file mode 100644 index 0000000000000..811bfaaae0d2c --- /dev/null +++ b/x-pack/test/functional/es_archives/canvas/lens/mappings.json @@ -0,0 +1,409 @@ +{ + "type": "index", + "value": { + "aliases": { + ".kibana": { + } + }, + "index": ".kibana_1", + "mappings": { + "dynamic": "strict", + "properties": { + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "id": { + "index": false, + "type": "text" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "lens": { + "properties": { + "expression": { + "index": false, + "type": "keyword" + }, + "state": { + "type": "flattened" + }, + "title": { + "type": "text" + }, + "visualizationType": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "type": "object" + }, + "namespace": { + "type": "keyword" + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "disabledFeatures": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} + +{ + "type": "index", + "value": { + "index": "logstash-lens", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "bytes": { + "type": "float" + } + } + }, + "settings": { + "index": { + "number_of_shards": "1", + "number_of_replicas": "0" + } + } + } +} \ No newline at end of file diff --git a/x-pack/test/functional/es_archives/visualize/default/data.json b/x-pack/test/functional/es_archives/visualize/default/data.json index fe29bad0fa381..26b033e28b4da 100644 --- a/x-pack/test/functional/es_archives/visualize/default/data.json +++ b/x-pack/test/functional/es_archives/visualize/default/data.json @@ -125,26 +125,8 @@ { "type": "doc", "value": { - "id": "custom-space:index-pattern:metricbeat-*", - "index": ".kibana_1", - "source": { - "index-pattern": { - "fieldFormatMap": "{\"aerospike.namespace.device.available.pct\":{\"id\":\"percent\",\"params\":{}},\"aerospike.namespace.device.free.pct\":{\"id\":\"percent\",\"params\":{}},\"aerospike.namespace.device.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aerospike.namespace.device.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aerospike.namespace.memory.free.pct\":{\"id\":\"percent\",\"params\":{}},\"aerospike.namespace.memory.used.data.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aerospike.namespace.memory.used.index.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aerospike.namespace.memory.used.sindex.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aerospike.namespace.memory.used.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.ec2.diskio.read.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.ec2.diskio.write.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.ec2.network.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.ec2.network.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.cpu.total.pct\":{\"id\":\"percent\",\"params\":{}},\"aws.rds.disk_usage.bin_log.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.free_local_storage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.free_storage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.freeable_memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.latency.commit\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.ddl\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.dml\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.insert\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.read\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.select\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.update\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.write\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.replica_lag.sec\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.swap_usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.volume_used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.s3_daily_storage.bucket.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.s3_request.downloaded.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.s3_request.latency.first_byte.ms\":{\"id\":\"duration\",\"params\":{}},\"aws.s3_request.latency.total_request.ms\":{\"id\":\"duration\",\"params\":{}},\"aws.s3_request.requests.select_returned.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.s3_request.requests.select_scanned.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.s3_request.uploaded.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.sqs.oldest_message_age.sec\":{\"id\":\"duration\",\"params\":{}},\"aws.sqs.sent_message_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_disk.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_disk.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_disk.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.degraded.ratio\":{\"id\":\"percent\",\"params\":{}},\"ceph.cluster_status.misplace.ratio\":{\"id\":\"percent\",\"params\":{}},\"ceph.cluster_status.pg.avail_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.pg.data_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.pg.total_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.pg.used_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.traffic.read_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.traffic.write_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.monitor_health.store_stats.log.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.monitor_health.store_stats.misc.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.monitor_health.store_stats.sst.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.monitor_health.store_stats.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.osd_df.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.osd_df.total.byte\":{\"id\":\"bytes\",\"params\":{}},\"ceph.osd_df.used.byte\":{\"id\":\"bytes\",\"params\":{}},\"ceph.osd_df.used.pct\":{\"id\":\"percent\",\"params\":{}},\"ceph.pool_disk.stats.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.pool_disk.stats.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"client.bytes\":{\"id\":\"bytes\",\"params\":{}},\"client.nat.port\":{\"id\":\"string\",\"params\":{}},\"client.port\":{\"id\":\"string\",\"params\":{}},\"coredns.stats.dns.request.duration.ns.sum\":{\"id\":\"duration\",\"params\":{}},\"couchbase.bucket.data.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.bucket.disk.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.bucket.memory.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.bucket.quota.ram.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.bucket.quota.use.pct\":{\"id\":\"percent\",\"params\":{}},\"couchbase.cluster.hdd.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.hdd.quota.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.hdd.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.hdd.used.by_data.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.hdd.used.value.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.quota.total.per_node.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.quota.total.value.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.quota.used.per_node.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.quota.used.value.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.used.by_data.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.used.value.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.node.couch.docs.data_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.node.couch.docs.disk_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.node.mcd_memory.allocated.bytes\":{\"id\":\"bytes\",\"params\":{}},\"destination.bytes\":{\"id\":\"bytes\",\"params\":{}},\"destination.nat.port\":{\"id\":\"string\",\"params\":{}},\"destination.port\":{\"id\":\"string\",\"params\":{}},\"docker.cpu.core.*.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.cpu.kernel.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.cpu.system.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.cpu.total.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.cpu.user.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.diskio.read.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.diskio.summary.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.diskio.write.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.commit.peak\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.commit.total\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.limit\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.private_working_set.total\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.rss.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.memory.rss.total\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.usage.max\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.usage.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.memory.usage.total\":{\"id\":\"bytes\",\"params\":{}},\"docker.network.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.network.inbound.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.network.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.network.outbound.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.summary.primaries.segments.memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.summary.primaries.store.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.summary.total.segments.memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.summary.total.store.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.total.segments.memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.total.store.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.jvm.memory.heap.init.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.jvm.memory.heap.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.jvm.memory.nonheap.init.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.jvm.memory.nonheap.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.fs.summary.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.fs.summary.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.fs.summary.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.indices.segments.memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.old.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.old.peak.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.old.peak_max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.old.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.survivor.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.survivor.peak.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.survivor.peak_max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.survivor.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.young.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.young.peak.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.young.peak_max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.young.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"etcd.disk.mvcc_db_total_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"etcd.memory.go_memstats_alloc.bytes\":{\"id\":\"bytes\",\"params\":{}},\"etcd.network.client_grpc_received.bytes\":{\"id\":\"bytes\",\"params\":{}},\"etcd.network.client_grpc_sent.bytes\":{\"id\":\"bytes\",\"params\":{}},\"event.duration\":{\"id\":\"duration\",\"params\":{\"inputFormat\":\"nanoseconds\",\"outputFormat\":\"asMilliseconds\",\"outputPrecision\":1}},\"event.sequence\":{\"id\":\"string\",\"params\":{}},\"event.severity\":{\"id\":\"string\",\"params\":{}},\"golang.heap.allocations.active\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.allocations.allocated\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.allocations.idle\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.allocations.total\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.gc.next_gc_limit\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.system.obtained\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.system.released\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.system.stack\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.system.total\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.info.idle.pct\":{\"id\":\"percent\",\"params\":{}},\"haproxy.info.memory.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.info.ssl.frontend.session_reuse.pct\":{\"id\":\"percent\",\"params\":{}},\"haproxy.stat.compressor.bypassed.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.compressor.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.compressor.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.compressor.response.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.throttle.pct\":{\"id\":\"percent\",\"params\":{}},\"http.request.body.bytes\":{\"id\":\"bytes\",\"params\":{}},\"http.request.bytes\":{\"id\":\"bytes\",\"params\":{}},\"http.response.body.bytes\":{\"id\":\"bytes\",\"params\":{}},\"http.response.bytes\":{\"id\":\"bytes\",\"params\":{}},\"http.response.status_code\":{\"id\":\"string\",\"params\":{}},\"kibana.stats.process.memory.heap.size_limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kibana.stats.process.memory.heap.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kibana.stats.process.memory.heap.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.apiserver.http.request.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.apiserver.http.response.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.apiserver.process.memory.resident.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.apiserver.process.memory.virtual.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.cpu.usage.limit.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.container.cpu.usage.node.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.container.logs.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.logs.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.logs.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.request.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.usage.limit.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.container.memory.usage.node.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.container.memory.workingset.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.rootfs.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.rootfs.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.rootfs.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.controllermanager.http.request.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.controllermanager.http.response.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.controllermanager.process.memory.resident.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.controllermanager.process.memory.virtual.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.fs.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.fs.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.fs.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.allocatable.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.workingset.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.network.rx.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.network.tx.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.runtime.imagefs.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.runtime.imagefs.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.runtime.imagefs.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.cpu.usage.limit.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.pod.cpu.usage.node.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.pod.memory.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.memory.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.memory.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.memory.usage.limit.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.pod.memory.usage.node.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.pod.memory.working_set.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.network.rx.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.network.tx.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.proxy.http.request.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.proxy.http.response.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.proxy.process.memory.resident.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.proxy.process.memory.virtual.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.scheduler.http.request.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.scheduler.http.response.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.scheduler.process.memory.resident.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.scheduler.process.memory.virtual.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.system.memory.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.system.memory.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.system.memory.workingset.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.volume.fs.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.volume.fs.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.volume.fs.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.avg_obj_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.data_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.extent_free_list.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.file_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.index_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.storage_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.replstatus.headroom.max\":{\"id\":\"duration\",\"params\":{}},\"mongodb.replstatus.headroom.min\":{\"id\":\"duration\",\"params\":{}},\"mongodb.replstatus.lag.max\":{\"id\":\"duration\",\"params\":{}},\"mongodb.replstatus.lag.min\":{\"id\":\"duration\",\"params\":{}},\"mongodb.replstatus.oplog.size.allocated\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.replstatus.oplog.size.used\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.extra_info.heap_usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.network.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.network.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.cache.dirty.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.cache.maximum.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.cache.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.log.max_file_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.log.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.log.write.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mysql.status.bytes.received\":{\"id\":\"bytes\",\"params\":{}},\"mysql.status.bytes.sent\":{\"id\":\"bytes\",\"params\":{}},\"nats.stats.cpu\":{\"id\":\"percent\",\"params\":{}},\"nats.stats.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"nats.stats.mem.bytes\":{\"id\":\"bytes\",\"params\":{}},\"nats.stats.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"nats.stats.uptime\":{\"id\":\"duration\",\"params\":{}},\"nats.subscriptions.cache.hit_rate\":{\"id\":\"percent\",\"params\":{}},\"network.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.data_file.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.data_file.size.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.data_file.size.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.space.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.space.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.space.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"process.pgid\":{\"id\":\"string\",\"params\":{}},\"process.pid\":{\"id\":\"string\",\"params\":{}},\"process.ppid\":{\"id\":\"string\",\"params\":{}},\"process.thread.id\":{\"id\":\"string\",\"params\":{}},\"rabbitmq.connection.frame_max\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.disk.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.disk.free.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.gc.reclaimed.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.io.read.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.io.write.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.mem.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.queue.consumers.utilisation.pct\":{\"id\":\"percent\",\"params\":{}},\"rabbitmq.queue.memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.allocator_stats.active\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.allocator_stats.allocated\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.allocator_stats.fragmentation.bytes\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.allocator_stats.resident\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.allocator_stats.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.fragmentation.bytes\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.max.value\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.used.dataset\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.used.lua\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.used.peak\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.used.rss\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.used.value\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.aof.buffer.size\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.aof.copy_on_write.last_size\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.aof.rewrite.buffer.size\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.aof.rewrite.current_time.sec\":{\"id\":\"duration\",\"params\":{}},\"redis.info.persistence.aof.rewrite.last_time.sec\":{\"id\":\"duration\",\"params\":{}},\"redis.info.persistence.aof.size.base\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.aof.size.current\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.rdb.bgsave.current_time.sec\":{\"id\":\"duration\",\"params\":{}},\"redis.info.persistence.rdb.bgsave.last_time.sec\":{\"id\":\"duration\",\"params\":{}},\"redis.info.persistence.rdb.copy_on_write.last_size\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.replication.backlog.size\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.replication.master.last_io_seconds_ago\":{\"id\":\"duration\",\"params\":{}},\"redis.info.replication.master.sync.last_io_seconds_ago\":{\"id\":\"duration\",\"params\":{}},\"redis.info.replication.master.sync.left_bytes\":{\"id\":\"bytes\",\"params\":{}},\"server.bytes\":{\"id\":\"bytes\",\"params\":{}},\"server.nat.port\":{\"id\":\"string\",\"params\":{}},\"server.port\":{\"id\":\"string\",\"params\":{}},\"source.bytes\":{\"id\":\"bytes\",\"params\":{}},\"source.nat.port\":{\"id\":\"string\",\"params\":{}},\"source.port\":{\"id\":\"string\",\"params\":{}},\"system.core.idle.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.iowait.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.irq.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.nice.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.softirq.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.steal.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.system.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.user.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.idle.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.idle.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.iowait.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.iowait.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.irq.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.irq.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.nice.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.nice.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.softirq.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.softirq.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.steal.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.steal.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.system.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.system.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.total.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.total.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.user.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.user.pct\":{\"id\":\"percent\",\"params\":{}},\"system.diskio.iostat.read.per_sec.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.diskio.iostat.write.per_sec.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.diskio.read.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.diskio.write.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.entropy.pct\":{\"id\":\"percent\",\"params\":{}},\"system.filesystem.available\":{\"id\":\"bytes\",\"params\":{}},\"system.filesystem.free\":{\"id\":\"bytes\",\"params\":{}},\"system.filesystem.total\":{\"id\":\"bytes\",\"params\":{}},\"system.filesystem.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.filesystem.used.pct\":{\"id\":\"percent\",\"params\":{}},\"system.fsstat.total_size.free\":{\"id\":\"bytes\",\"params\":{}},\"system.fsstat.total_size.total\":{\"id\":\"bytes\",\"params\":{}},\"system.fsstat.total_size.used\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.actual.free\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.actual.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.actual.used.pct\":{\"id\":\"percent\",\"params\":{}},\"system.memory.free\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.hugepages.default_size\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.hugepages.free\":{\"id\":\"number\",\"params\":{}},\"system.memory.hugepages.reserved\":{\"id\":\"number\",\"params\":{}},\"system.memory.hugepages.surplus\":{\"id\":\"number\",\"params\":{}},\"system.memory.hugepages.total\":{\"id\":\"number\",\"params\":{}},\"system.memory.hugepages.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.hugepages.used.pct\":{\"id\":\"percent\",\"params\":{}},\"system.memory.swap.free\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.swap.total\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.swap.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.swap.used.pct\":{\"id\":\"percent\",\"params\":{}},\"system.memory.total\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.used.pct\":{\"id\":\"percent\",\"params\":{}},\"system.network.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.network.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.blkio.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem.usage.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem_tcp.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem_tcp.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem_tcp.usage.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.mem.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.mem.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.mem.usage.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.memsw.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.memsw.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.memsw.usage.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.active_anon.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.active_file.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.cache.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.hierarchical_memory_limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.hierarchical_memsw_limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.inactive_anon.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.inactive_file.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.mapped_file.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.rss_huge.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.swap.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.unevictable.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cpu.total.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.process.cpu.total.pct\":{\"id\":\"percent\",\"params\":{}},\"system.process.memory.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.memory.rss.pct\":{\"id\":\"percent\",\"params\":{}},\"system.process.memory.share\":{\"id\":\"bytes\",\"params\":{}},\"system.process.memory.size\":{\"id\":\"bytes\",\"params\":{}},\"system.socket.summary.tcp.memory\":{\"id\":\"bytes\",\"params\":{}},\"system.socket.summary.udp.memory\":{\"id\":\"bytes\",\"params\":{}},\"system.uptime.duration.ms\":{\"id\":\"duration\",\"params\":{\"inputFormat\":\"milliseconds\"}},\"url.port\":{\"id\":\"string\",\"params\":{}},\"vsphere.datastore.capacity.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.datastore.capacity.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.datastore.capacity.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.datastore.capacity.used.pct\":{\"id\":\"percent\",\"params\":{}},\"vsphere.host.memory.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.host.memory.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.host.memory.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.virtualmachine.memory.free.guest.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.virtualmachine.memory.total.guest.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.virtualmachine.memory.used.guest.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.virtualmachine.memory.used.host.bytes\":{\"id\":\"bytes\",\"params\":{}},\"windows.service.uptime.ms\":{\"id\":\"duration\",\"params\":{\"inputFormat\":\"milliseconds\"}}}", - "timeFieldName": "@timestamp", - "title": "metricbeat-*" - }, - "migrationVersion": { - "index-pattern": "7.6.0" - }, - "type": "index-pattern", - "updated_at": "2020-01-22T15:34:59.061Z" - } - } -} - -{ - "type": "doc", - "value": { + "index": ".kibana", + "type": "doc", "id": "index-pattern:logstash-*", "index": ".kibana_1", "source": { @@ -297,4 +279,4 @@ "updated_at": "2019-07-17T17:54:26.378Z" } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index dabead6ffbdad..17f9fb036129a 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -202,7 +202,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont }) .lnsDragDrop`; const dropping = `[data-test-subj='${dimension}']:nth-of-type(${ endIndex + 1 - }) [data-test-subj='lnsDragDrop-reorderableDrop'`; + }) [data-test-subj='lnsDragDrop-reorderableDropLayer'`; await browser.html5DragAndDrop(dragging, dropping); await PageObjects.header.waitUntilLoadingHasFinished(); }, diff --git a/x-pack/test/functional/services/canvas_element.ts b/x-pack/test/functional/services/canvas_element.ts index e2a42c5dc43c3..08ac38d970225 100644 --- a/x-pack/test/functional/services/canvas_element.ts +++ b/x-pack/test/functional/services/canvas_element.ts @@ -43,9 +43,13 @@ export async function CanvasElementProvider({ getService }: FtrProviderContext) public async getImageData(selector: string): Promise { return await driver.executeScript( ` - const el = document.querySelector('${selector}'); - const ctx = el.getContext('2d'); - return ctx.getImageData(0, 0, el.width, el.height).data; + try { + const el = document.querySelector('${selector}'); + const ctx = el.getContext('2d'); + return ctx.getImageData(0, 0, el.width, el.height).data; + } catch(e) { + return []; + } ` ); } diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_scatterplot.ts b/x-pack/test/functional/services/ml/data_frame_analytics_scatterplot.ts new file mode 100644 index 0000000000000..3472e5079c79a --- /dev/null +++ b/x-pack/test/functional/services/ml/data_frame_analytics_scatterplot.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export function MachineLearningDataFrameAnalyticsScatterplotProvider({ + getService, +}: FtrProviderContext) { + const canvasElement = getService('canvasElement'); + const testSubjects = getService('testSubjects'); + + return new (class AnalyticsScatterplot { + public async assertScatterplotMatrix( + dataTestSubj: string, + expectedColorStats: Array<{ + key: string; + value: number; + }> + ) { + await testSubjects.existOrFail(dataTestSubj); + await testSubjects.existOrFail('mlScatterplotMatrix'); + + const actualColorStats = await canvasElement.getColorStats( + `[data-test-subj="mlScatterplotMatrix"] canvas`, + expectedColorStats, + 1 + ); + expect(actualColorStats.every((d) => d.withinTolerance)).to.eql( + true, + `Color stats for scatterplot matrix should be within tolerance. Expected: '${JSON.stringify( + expectedColorStats + )}' (got '${JSON.stringify(actualColorStats)}')` + ); + } + })(); +} diff --git a/x-pack/test/functional/services/ml/index.ts b/x-pack/test/functional/services/ml/index.ts index c1a9ac304dd69..aa87bc5dc4772 100644 --- a/x-pack/test/functional/services/ml/index.ts +++ b/x-pack/test/functional/services/ml/index.ts @@ -17,6 +17,7 @@ import { MachineLearningDataFrameAnalyticsProvider } from './data_frame_analytic import { MachineLearningDataFrameAnalyticsCreationProvider } from './data_frame_analytics_creation'; import { MachineLearningDataFrameAnalyticsEditProvider } from './data_frame_analytics_edit'; import { MachineLearningDataFrameAnalyticsResultsProvider } from './data_frame_analytics_results'; +import { MachineLearningDataFrameAnalyticsScatterplotProvider } from './data_frame_analytics_scatterplot'; import { MachineLearningDataFrameAnalyticsMapProvider } from './data_frame_analytics_map'; import { MachineLearningDataFrameAnalyticsTableProvider } from './data_frame_analytics_table'; import { MachineLearningDataVisualizerProvider } from './data_visualizer'; @@ -63,6 +64,9 @@ export function MachineLearningProvider(context: FtrProviderContext) { const dataFrameAnalyticsResults = MachineLearningDataFrameAnalyticsResultsProvider(context); const dataFrameAnalyticsMap = MachineLearningDataFrameAnalyticsMapProvider(context); const dataFrameAnalyticsTable = MachineLearningDataFrameAnalyticsTableProvider(context); + const dataFrameAnalyticsScatterplot = MachineLearningDataFrameAnalyticsScatterplotProvider( + context + ); const dataVisualizer = MachineLearningDataVisualizerProvider(context); const dataVisualizerTable = MachineLearningDataVisualizerTableProvider(context, commonUI); @@ -105,6 +109,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { dataFrameAnalyticsResults, dataFrameAnalyticsMap, dataFrameAnalyticsTable, + dataFrameAnalyticsScatterplot, dataVisualizer, dataVisualizerFileBased, dataVisualizerIndexBased, diff --git a/x-pack/test/reporting_api_integration/reporting_without_security.config.ts b/x-pack/test/reporting_api_integration/reporting_without_security.config.ts index 11182bbcdb3b0..4a95a15169b59 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security.config.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security.config.ts @@ -32,7 +32,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { kbnTestServer: { ...apiConfig.get('kbnTestServer'), serverArgs: [ - `--migrations.enableV2=false`, `--elasticsearch.hosts=${formatUrl(esTestConfig.getUrlParts())}`, `--logging.json=false`, `--server.maxPayloadBytes=1679958`, diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts index 7e878e763bfc1..3e417551c3cb9 100644 --- a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts +++ b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts @@ -96,6 +96,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // should leave session state untouched await PageObjects.dashboard.switchToEditMode(); await searchSessions.expectState('restored'); + + // navigating to a listing page clears the session + await PageObjects.dashboard.gotoDashboardLandingPage(); + await searchSessions.missingOrFail(); }); }); } diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background_relative_time.ts b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background_relative_time.ts index ce6c8978c7d67..25291fd74b322 100644 --- a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background_relative_time.ts +++ b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background_relative_time.ts @@ -18,6 +18,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'visChart', 'home', 'timePicker', + 'maps', ]); const dashboardPanelActions = getService('dashboardPanelActions'); const inspector = getService('inspector'); @@ -112,5 +113,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { log.debug('Checking vega chart rendered'); const tsvb = await find.existsByCssSelector('.vgaVis__view'); expect(tsvb).to.be(true); + log.debug('Checking map rendered'); + await dashboardPanelActions.openInspectorByTitle( + '[Flights] Origin and Destination Flight Time' + ); + await testSubjects.click('inspectorRequestChooser'); + await testSubjects.click(`inspectorRequestChooserFlight Origin Location`); + const requestStats = await inspector.getTableData(); + const totalHits = PageObjects.maps.getInspectorStatRowHit(requestStats, 'Hits'); + expect(totalHits).to.equal('0'); + await inspector.close(); } } diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index cc36a2c93b1a0..12cd2896faaa8 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -24,6 +24,7 @@ { "path": "../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../src/plugins/kibana_usage_collection/tsconfig.json" }, { "path": "../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../src/plugins/legacy_export/tsconfig.json" }, { "path": "../../src/plugins/navigation/tsconfig.json" }, { "path": "../../src/plugins/newsfeed/tsconfig.json" }, { "path": "../../src/plugins/saved_objects/tsconfig.json" }, @@ -36,10 +37,11 @@ { "path": "../../src/plugins/ui_actions/tsconfig.json" }, { "path": "../../src/plugins/url_forwarding/tsconfig.json" }, - { "path": "../plugins/actions/tsconfig.json"}, - { "path": "../plugins/alerts/tsconfig.json"}, + { "path": "../plugins/actions/tsconfig.json" }, + { "path": "../plugins/alerts/tsconfig.json" }, { "path": "../plugins/console_extensions/tsconfig.json" }, { "path": "../plugins/data_enhanced/tsconfig.json" }, + { "path": "../plugins/enterprise_search/tsconfig.json" }, { "path": "../plugins/global_search/tsconfig.json" }, { "path": "../plugins/global_search_providers/tsconfig.json" }, { "path": "../plugins/features/tsconfig.json" }, @@ -60,6 +62,7 @@ { "path": "../plugins/saved_objects_tagging/tsconfig.json" }, { "path": "../plugins/global_search_bar/tsconfig.json" }, { "path": "../plugins/license_management/tsconfig.json" }, + { "path": "../plugins/painless_lab/tsconfig.json" }, { "path": "../plugins/watcher/tsconfig.json" } ] } diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 956bd409f979d..85e285f3c83ac 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -7,6 +7,7 @@ "plugins/apm/e2e/cypress/**/*", "plugins/apm/ftr_e2e/**/*", "plugins/apm/scripts/**/*", + "plugins/canvas/**/*", "plugins/console_extensions/**/*", "plugins/data_enhanced/**/*", "plugins/discover_enhanced/**/*", @@ -17,11 +18,13 @@ "plugins/features/**/*", "plugins/embeddable_enhanced/**/*", "plugins/event_log/**/*", + "plugins/enterprise_search/**/*", "plugins/licensing/**/*", "plugins/lens/**/*", "plugins/maps/**/*", "plugins/maps_file_upload/**/*", "plugins/maps_legacy_licensing/**/*", + "plugins/reporting/**/*", "plugins/searchprofiler/**/*", "plugins/security_solution/cypress/**/*", "plugins/task_manager/**/*", @@ -38,6 +41,7 @@ "plugins/saved_objects_tagging/**/*", "plugins/global_search_bar/**/*", "plugins/license_management/**/*", + "plugins/painless_lab/**/*", "plugins/watcher/**/*", "test/**/*" ], @@ -47,15 +51,13 @@ }, "references": [ { "path": "../src/core/tsconfig.json" }, - { "path": "../src/plugins/telemetry_management_section/tsconfig.json" }, - { "path": "../src/plugins/management/tsconfig.json" }, { "path": "../src/plugins/bfetch/tsconfig.json" }, { "path": "../src/plugins/charts/tsconfig.json" }, { "path": "../src/plugins/console/tsconfig.json" }, { "path": "../src/plugins/dashboard/tsconfig.json" }, - { "path": "../src/plugins/discover/tsconfig.json" }, { "path": "../src/plugins/data/tsconfig.json" }, { "path": "../src/plugins/dev_tools/tsconfig.json" }, + { "path": "../src/plugins/discover/tsconfig.json" }, { "path": "../src/plugins/embeddable/tsconfig.json" }, { "path": "../src/plugins/es_ui_shared/tsconfig.json" }, { "path": "../src/plugins/expressions/tsconfig.json" }, @@ -65,51 +67,56 @@ { "path": "../src/plugins/kibana_react/tsconfig.json" }, { "path": "../src/plugins/kibana_usage_collection/tsconfig.json" }, { "path": "../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../src/plugins/legacy_export/tsconfig.json" }, + { "path": "../src/plugins/management/tsconfig.json" }, { "path": "../src/plugins/navigation/tsconfig.json" }, { "path": "../src/plugins/newsfeed/tsconfig.json" }, - { "path": "../src/plugins/saved_objects/tsconfig.json" }, + { "path": "../src/plugins/presentation_util/tsconfig.json" }, { "path": "../src/plugins/saved_objects_management/tsconfig.json" }, { "path": "../src/plugins/saved_objects_tagging_oss/tsconfig.json" }, - { "path": "../src/plugins/presentation_util/tsconfig.json" }, + { "path": "../src/plugins/saved_objects/tsconfig.json" }, { "path": "../src/plugins/security_oss/tsconfig.json" }, { "path": "../src/plugins/share/tsconfig.json" }, - { "path": "../src/plugins/telemetry/tsconfig.json" }, { "path": "../src/plugins/telemetry_collection_manager/tsconfig.json" }, - { "path": "../src/plugins/url_forwarding/tsconfig.json" }, + { "path": "../src/plugins/telemetry_management_section/tsconfig.json" }, + { "path": "../src/plugins/telemetry/tsconfig.json" }, { "path": "../src/plugins/ui_actions/tsconfig.json" }, { "path": "../src/plugins/url_forwarding/tsconfig.json" }, { "path": "../src/plugins/usage_collection/tsconfig.json" }, + { "path": "./plugins/actions/tsconfig.json" }, + { "path": "./plugins/alerts/tsconfig.json" }, + { "path": "./plugins/beats_management/tsconfig.json" }, + { "path": "./plugins/canvas/tsconfig.json" }, + { "path": "./plugins/cloud/tsconfig.json" }, { "path": "./plugins/console_extensions/tsconfig.json" }, { "path": "./plugins/data_enhanced/tsconfig.json" }, { "path": "./plugins/discover_enhanced/tsconfig.json" }, - { "path": "./plugins/global_search/tsconfig.json" }, - { "path": "./plugins/global_search_providers/tsconfig.json" }, - { "path": "./plugins/features/tsconfig.json" }, - { "path": "./plugins/graph/tsconfig.json" }, { "path": "./plugins/embeddable_enhanced/tsconfig.json" }, + { "path": "./plugins/encrypted_saved_objects/tsconfig.json" }, + { "path": "./plugins/enterprise_search/tsconfig.json" }, { "path": "./plugins/event_log/tsconfig.json" }, - { "path": "./plugins/licensing/tsconfig.json" }, + { "path": "./plugins/features/tsconfig.json" }, + { "path": "./plugins/global_search_bar/tsconfig.json" }, + { "path": "./plugins/global_search_providers/tsconfig.json" }, + { "path": "./plugins/global_search/tsconfig.json" }, + { "path": "./plugins/graph/tsconfig.json" }, { "path": "./plugins/lens/tsconfig.json" }, - { "path": "./plugins/maps/tsconfig.json" }, + { "path": "./plugins/license_management/tsconfig.json" }, + { "path": "./plugins/licensing/tsconfig.json" }, { "path": "./plugins/maps_file_upload/tsconfig.json" }, { "path": "./plugins/maps_legacy_licensing/tsconfig.json" }, + { "path": "./plugins/maps/tsconfig.json" }, + { "path": "./plugins/painless_lab/tsconfig.json" }, + { "path": "./plugins/saved_objects_tagging/tsconfig.json" }, { "path": "./plugins/searchprofiler/tsconfig.json" }, + { "path": "./plugins/security/tsconfig.json" }, + { "path": "./plugins/spaces/tsconfig.json" }, + { "path": "./plugins/stack_alerts/tsconfig.json" }, { "path": "./plugins/task_manager/tsconfig.json" }, { "path": "./plugins/telemetry_collection_xpack/tsconfig.json" }, - { "path": "./plugins/ui_actions_enhanced/tsconfig.json" }, { "path": "./plugins/translations/tsconfig.json" }, - { "path": "./plugins/spaces/tsconfig.json" }, - { "path": "./plugins/security/tsconfig.json" }, - { "path": "./plugins/encrypted_saved_objects/tsconfig.json" }, - { "path": "./plugins/beats_management/tsconfig.json" }, - { "path": "./plugins/cloud/tsconfig.json" }, - { "path": "./plugins/saved_objects_tagging/tsconfig.json" }, - { "path": "./plugins/global_search_bar/tsconfig.json" }, - { "path": "./plugins/actions/tsconfig.json"}, - { "path": "./plugins/alerts/tsconfig.json"}, - { "path": "./plugins/triggers_actions_ui/tsconfig.json"}, - { "path": "./plugins/stack_alerts/tsconfig.json"}, - { "path": "./plugins/license_management/tsconfig.json" }, - { "path": "./plugins/watcher/tsconfig.json" }, + { "path": "./plugins/triggers_actions_ui/tsconfig.json" }, + { "path": "./plugins/ui_actions_enhanced/tsconfig.json" }, + { "path": "./plugins/watcher/tsconfig.json" } ] } diff --git a/x-pack/tsconfig.refs.json b/x-pack/tsconfig.refs.json index 1724cb2afbffa..ed209cd241586 100644 --- a/x-pack/tsconfig.refs.json +++ b/x-pack/tsconfig.refs.json @@ -3,36 +3,40 @@ "references": [ { "path": "./plugins/actions/tsconfig.json"}, { "path": "./plugins/alerts/tsconfig.json"}, - { "path": "./plugins/dashboard_enhanced/tsconfig.json" }, - { "path": "./plugins/licensing/tsconfig.json" }, - { "path": "./plugins/lens/tsconfig.json" }, + { "path": "./plugins/beats_management/tsconfig.json" }, + { "path": "./plugins/canvas/tsconfig.json" }, + { "path": "./plugins/cloud/tsconfig.json" }, { "path": "./plugins/console_extensions/tsconfig.json" }, - { "path": "./plugins/discover_enhanced/tsconfig.json" }, + { "path": "./plugins/dashboard_enhanced/tsconfig.json" }, { "path": "./plugins/data_enhanced/tsconfig.json" }, - { "path": "./plugins/global_search/tsconfig.json" }, - { "path": "./plugins/global_search_providers/tsconfig.json" }, + { "path": "./plugins/discover_enhanced/tsconfig.json" }, + { "path": "./plugins/embeddable_enhanced/tsconfig.json" }, + { "path": "./plugins/encrypted_saved_objects/tsconfig.json" }, + { "path": "./plugins/enterprise_search/tsconfig.json" }, { "path": "./plugins/event_log/tsconfig.json"}, { "path": "./plugins/features/tsconfig.json" }, + { "path": "./plugins/global_search_bar/tsconfig.json" }, + { "path": "./plugins/global_search_providers/tsconfig.json" }, + { "path": "./plugins/global_search/tsconfig.json" }, { "path": "./plugins/graph/tsconfig.json" }, - { "path": "./plugins/embeddable_enhanced/tsconfig.json" }, - { "path": "./plugins/maps/tsconfig.json" }, + { "path": "./plugins/lens/tsconfig.json" }, + { "path": "./plugins/license_management/tsconfig.json" }, + { "path": "./plugins/licensing/tsconfig.json" }, { "path": "./plugins/maps_file_upload/tsconfig.json" }, { "path": "./plugins/maps_legacy_licensing/tsconfig.json" }, + { "path": "./plugins/maps/tsconfig.json" }, + { "path": "./plugins/painless_lab/tsconfig.json" }, + { "path": "./plugins/reporting/tsconfig.json" }, + { "path": "./plugins/saved_objects_tagging/tsconfig.json" }, { "path": "./plugins/searchprofiler/tsconfig.json" }, + { "path": "./plugins/security/tsconfig.json" }, + { "path": "./plugins/spaces/tsconfig.json" }, + { "path": "./plugins/stack_alerts/tsconfig.json"}, { "path": "./plugins/task_manager/tsconfig.json" }, { "path": "./plugins/telemetry_collection_xpack/tsconfig.json" }, - { "path": "./plugins/ui_actions_enhanced/tsconfig.json" }, { "path": "./plugins/translations/tsconfig.json" }, { "path": "./plugins/triggers_actions_ui/tsconfig.json"}, - { "path": "./plugins/spaces/tsconfig.json" }, - { "path": "./plugins/security/tsconfig.json" }, - { "path": "./plugins/stack_alerts/tsconfig.json"}, - { "path": "./plugins/encrypted_saved_objects/tsconfig.json" }, - { "path": "./plugins/beats_management/tsconfig.json" }, - { "path": "./plugins/cloud/tsconfig.json" }, - { "path": "./plugins/saved_objects_tagging/tsconfig.json" }, - { "path": "./plugins/global_search_bar/tsconfig.json" }, - { "path": "./plugins/license_management/tsconfig.json" }, + { "path": "./plugins/ui_actions_enhanced/tsconfig.json" }, { "path": "./plugins/watcher/tsconfig.json" } ] }