From 2a82ff9566423a16e1f976c9d0d2db91acf006a9 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Sat, 25 Jul 2020 12:59:56 +0300 Subject: [PATCH 01/17] [KP] use new ES client in SO service (#72289) * adapt retryCallCluster for new ES client * review comments * retry on 408 ResponseError * remove legacy retry functions * use Migrator Es client in SO migration * update migration tests * improve ES typings and mocks * migrate decorate ES errors * add repository es client * use new es client in so repository * update repository tests * fix migrator integration tests * declare _seq_no & _primary_term on get response. _source expect to be a string * make _sourceIncludes and refresh compatible with the client * add test for repository_es_client * move ApiResponse to es client mocks * TEMP: handle wait_for as true for deleteByNamespace * add tests for migration_es_client * TEMP: skip test for deleteByNamespace refresh * pass ignore as transport option in mget * log both es client and response errors * fix update method test failures * update deleteByNamespace refresh settings es doesn't support 'refresh: wait_for' for `updateByQuery` endpoint * update repository tests. we do not allow customising wait_for * do not delegate retry logic to es client * fix type errors after master merged * fix repository tests * fix security solutions code SO doesn't throw Error with status code anymore. Always use SO error helpers * switch error conditions to use SO error helpers * cleanup * address comments about mocks * use isResponseError helper * address comments * fix type errors Co-authored-by: pgayvallet --- .../client/configure_client.test.ts | 30 +- .../elasticsearch/client/configure_client.ts | 13 +- src/core/server/elasticsearch/client/index.ts | 2 +- src/core/server/elasticsearch/client/mocks.ts | 34 +- .../client/retry_call_cluster.test.ts | 35 +- .../client/retry_call_cluster.ts | 2 +- src/core/server/elasticsearch/client/types.ts | 80 + .../elasticsearch_service.test.ts | 6 +- src/core/server/elasticsearch/index.ts | 4 + src/core/server/elasticsearch/legacy/index.ts | 1 - .../legacy/retry_call_cluster.test.ts | 147 -- .../legacy/retry_call_cluster.ts | 115 -- .../version_check/ensure_es_version.test.ts | 4 +- .../integration_tests/core_services.test.ts | 4 +- src/core/server/index.ts | 1 + .../__snapshots__/elastic_index.test.ts.snap | 1 - .../migrations/core/elastic_index.test.ts | 608 +++---- .../migrations/core/elastic_index.ts | 119 +- .../saved_objects/migrations/core/index.ts | 1 + .../migrations/core/index_migrator.test.ts | 176 +- .../migrations/core/index_migrator.ts | 27 +- .../migrations/core/migration_context.ts | 27 +- .../core/migration_es_client.test.mock.ts | 22 + .../core/migration_es_client.test.ts | 65 + .../migrations/core/migration_es_client.ts | 90 + .../migrations/kibana/kibana_migrator.test.ts | 53 +- .../migrations/kibana/kibana_migrator.ts | 20 +- .../saved_objects_service.test.ts | 45 +- .../saved_objects/saved_objects_service.ts | 34 +- .../saved_objects/serialization/index.ts | 7 +- .../service/lib/decorate_es_error.test.ts | 101 +- .../service/lib/decorate_es_error.ts | 55 +- .../service/lib/repository.test.js | 1489 ++++++++++------- .../saved_objects/service/lib/repository.ts | 405 ++--- .../lib/repository_es_client.test.mock.ts | 22 + .../service/lib/repository_es_client.test.ts | 64 + .../service/lib/repository_es_client.ts | 56 + .../services/sample_data/routes/list.ts | 4 +- .../{migrations.js => migrations.ts} | 171 +- .../apm/server/lib/apm_telemetry/index.ts | 8 +- .../server/routes/agent/handlers.ts | 2 +- .../exception_lists/create_endpoint_list.ts | 2 +- .../server/endpoint/routes/metadata/index.ts | 12 +- .../endpoint/routes/metadata/metadata.test.ts | 6 +- .../manifest_manager/manifest_manager.ts | 2 +- .../lib/reindexing/reindex_actions.test.ts | 7 +- .../server/lib/reindexing/reindex_actions.ts | 2 +- 47 files changed, 2383 insertions(+), 1798 deletions(-) delete mode 100644 src/core/server/elasticsearch/legacy/retry_call_cluster.test.ts delete mode 100644 src/core/server/elasticsearch/legacy/retry_call_cluster.ts create mode 100644 src/core/server/saved_objects/migrations/core/migration_es_client.test.mock.ts create mode 100644 src/core/server/saved_objects/migrations/core/migration_es_client.test.ts create mode 100644 src/core/server/saved_objects/migrations/core/migration_es_client.ts create mode 100644 src/core/server/saved_objects/service/lib/repository_es_client.test.mock.ts create mode 100644 src/core/server/saved_objects/service/lib/repository_es_client.test.ts create mode 100644 src/core/server/saved_objects/service/lib/repository_es_client.ts rename test/api_integration/apis/saved_objects/{migrations.js => migrations.ts} (68%) diff --git a/src/core/server/elasticsearch/client/configure_client.test.ts b/src/core/server/elasticsearch/client/configure_client.test.ts index 32da142764a78..11e3199a79fd2 100644 --- a/src/core/server/elasticsearch/client/configure_client.test.ts +++ b/src/core/server/elasticsearch/client/configure_client.test.ts @@ -118,26 +118,40 @@ describe('configureClient', () => { }); describe('Client logging', () => { - it('logs error when the client emits an error', () => { + it('logs error when the client emits an @elastic/elasticsearch error', () => { + const client = configureClient(config, { logger, scoped: false }); + + const response = createApiResponse({ body: {} }); + client.emit('response', new errors.TimeoutError('message', response), response); + + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + "[TimeoutError]: message", + ], + ] + `); + }); + + it('logs error when the client emits an ResponseError returned by elasticsearch', () => { const client = configureClient(config, { logger, scoped: false }); const response = createApiResponse({ + statusCode: 400, + headers: {}, body: { error: { - type: 'error message', + type: 'illegal_argument_exception', + reason: 'request [/_path] contains unrecognized parameter: [name]', }, }, }); - client.emit('response', new errors.ResponseError(response), null); - client.emit('response', new Error('some error'), null); + client.emit('response', new errors.ResponseError(response), response); expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ - "ResponseError: error message", - ], - Array [ - "Error: some error", + "[illegal_argument_exception]: request [/_path] contains unrecognized parameter: [name]", ], ] `); diff --git a/src/core/server/elasticsearch/client/configure_client.ts b/src/core/server/elasticsearch/client/configure_client.ts index 5377f8ca1b070..9746ecb538b75 100644 --- a/src/core/server/elasticsearch/client/configure_client.ts +++ b/src/core/server/elasticsearch/client/configure_client.ts @@ -21,6 +21,7 @@ import { stringify } from 'querystring'; import { Client } from '@elastic/elasticsearch'; import { Logger } from '../../logging'; import { parseClientOptions, ElasticsearchClientConfig } from './client_config'; +import { isResponseError } from './errors'; export const configureClient = ( config: ElasticsearchClientConfig, @@ -35,9 +36,15 @@ export const configureClient = ( }; const addLogging = (client: Client, logger: Logger, logQueries: boolean) => { - client.on('response', (err, event) => { - if (err) { - logger.error(`${err.name}: ${err.message}`); + client.on('response', (error, event) => { + if (error) { + const errorMessage = + // error details for response errors provided by elasticsearch + isResponseError(error) + ? `[${event.body.error.type}]: ${event.body.error.reason}` + : `[${error.name}]: ${error.message}`; + + logger.error(errorMessage); } if (event && logQueries) { const params = event.meta.request.params; diff --git a/src/core/server/elasticsearch/client/index.ts b/src/core/server/elasticsearch/client/index.ts index b8125de2ee498..af63dfa6c7f4e 100644 --- a/src/core/server/elasticsearch/client/index.ts +++ b/src/core/server/elasticsearch/client/index.ts @@ -17,7 +17,7 @@ * under the License. */ -export { ElasticsearchClient } from './types'; +export * from './types'; export { IScopedClusterClient, ScopedClusterClient } from './scoped_cluster_client'; export { ElasticsearchClientConfig } from './client_config'; export { IClusterClient, ICustomClusterClient, ClusterClient } from './cluster_client'; diff --git a/src/core/server/elasticsearch/client/mocks.ts b/src/core/server/elasticsearch/client/mocks.ts index ec2885dfdf922..c93294404b52f 100644 --- a/src/core/server/elasticsearch/client/mocks.ts +++ b/src/core/server/elasticsearch/client/mocks.ts @@ -45,7 +45,7 @@ const createInternalClientMock = (): DeeplyMockedKeys => { .forEach((key) => { const propType = typeof obj[key]; if (propType === 'function') { - obj[key] = jest.fn(); + obj[key] = jest.fn(() => createSuccessTransportRequestPromise({})); } else if (propType === 'object' && obj[key] != null) { mockify(obj[key]); } @@ -70,6 +70,7 @@ const createInternalClientMock = (): DeeplyMockedKeys => { return (mock as unknown) as DeeplyMockedKeys; }; +// TODO fix naming ElasticsearchClientMock export type ElasticSearchClientMock = DeeplyMockedKeys; const createClientMock = (): ElasticSearchClientMock => @@ -124,32 +125,41 @@ export type MockedTransportRequestPromise = TransportRequestPromise & { abort: jest.MockedFunction<() => undefined>; }; -const createMockedClientResponse = (body: T): MockedTransportRequestPromise> => { - const response: ApiResponse = { - body, - statusCode: 200, - warnings: [], - headers: {}, - meta: {} as any, - }; +const createSuccessTransportRequestPromise = ( + body: T, + { statusCode = 200 }: { statusCode?: number } = {} +): MockedTransportRequestPromise> => { + const response = createApiResponse({ body, statusCode }); const promise = Promise.resolve(response); (promise as MockedTransportRequestPromise>).abort = jest.fn(); return promise as MockedTransportRequestPromise>; }; -const createMockedClientError = (err: any): MockedTransportRequestPromise => { +const createErrorTransportRequestPromise = (err: any): MockedTransportRequestPromise => { const promise = Promise.reject(err); (promise as MockedTransportRequestPromise).abort = jest.fn(); return promise as MockedTransportRequestPromise; }; +function createApiResponse(opts: Partial = {}): ApiResponse { + return { + body: {}, + statusCode: 200, + headers: {}, + warnings: [], + meta: {} as any, + ...opts, + }; +} + export const elasticsearchClientMock = { createClusterClient: createClusterClientMock, createCustomClusterClient: createCustomClusterClientMock, createScopedClusterClient: createScopedClusterClientMock, createElasticSearchClient: createClientMock, createInternalClient: createInternalClientMock, - createClientResponse: createMockedClientResponse, - createClientError: createMockedClientError, + createSuccessTransportRequestPromise, + createErrorTransportRequestPromise, + createApiResponse, }; diff --git a/src/core/server/elasticsearch/client/retry_call_cluster.test.ts b/src/core/server/elasticsearch/client/retry_call_cluster.test.ts index a7177c0b29047..3aa47e8b40e24 100644 --- a/src/core/server/elasticsearch/client/retry_call_cluster.test.ts +++ b/src/core/server/elasticsearch/client/retry_call_cluster.test.ts @@ -23,7 +23,8 @@ import { loggingSystemMock } from '../../logging/logging_system.mock'; import { retryCallCluster, migrationRetryCallCluster } from './retry_call_cluster'; const dummyBody = { foo: 'bar' }; -const createErrorReturn = (err: any) => elasticsearchClientMock.createClientError(err); +const createErrorReturn = (err: any) => + elasticsearchClientMock.createErrorTransportRequestPromise(err); describe('retryCallCluster', () => { let client: ReturnType; @@ -33,7 +34,9 @@ describe('retryCallCluster', () => { }); it('returns response from ES API call in case of success', async () => { - const successReturn = elasticsearchClientMock.createClientResponse({ ...dummyBody }); + const successReturn = elasticsearchClientMock.createSuccessTransportRequestPromise({ + ...dummyBody, + }); client.asyncSearch.get.mockReturnValue(successReturn); @@ -42,7 +45,9 @@ describe('retryCallCluster', () => { }); it('retries ES API calls that rejects with `NoLivingConnectionsError`', async () => { - const successReturn = elasticsearchClientMock.createClientResponse({ ...dummyBody }); + const successReturn = elasticsearchClientMock.createSuccessTransportRequestPromise({ + ...dummyBody, + }); client.asyncSearch.get .mockImplementationOnce(() => @@ -57,7 +62,9 @@ describe('retryCallCluster', () => { it('rejects when ES API calls reject with other errors', async () => { client.ping .mockImplementationOnce(() => createErrorReturn(new Error('unknown error'))) - .mockImplementationOnce(() => elasticsearchClientMock.createClientResponse({ ...dummyBody })); + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ ...dummyBody }) + ); await expect(retryCallCluster(() => client.ping())).rejects.toMatchInlineSnapshot( `[Error: unknown error]` @@ -73,7 +80,9 @@ describe('retryCallCluster', () => { createErrorReturn(new errors.NoLivingConnectionsError('no living connections', {} as any)) ) .mockImplementationOnce(() => createErrorReturn(new Error('unknown error'))) - .mockImplementationOnce(() => elasticsearchClientMock.createClientResponse({ ...dummyBody })); + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ ...dummyBody }) + ); await expect(retryCallCluster(() => client.ping())).rejects.toMatchInlineSnapshot( `[Error: unknown error]` @@ -94,7 +103,9 @@ describe('migrationRetryCallCluster', () => { client.ping .mockImplementationOnce(() => createErrorReturn(error)) .mockImplementationOnce(() => createErrorReturn(error)) - .mockImplementationOnce(() => elasticsearchClientMock.createClientResponse({ ...dummyBody })); + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ ...dummyBody }) + ); }; it('retries ES API calls that rejects with `NoLivingConnectionsError`', async () => { @@ -225,7 +236,9 @@ describe('migrationRetryCallCluster', () => { } as any) ) ) - .mockImplementationOnce(() => elasticsearchClientMock.createClientResponse({ ...dummyBody })); + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ ...dummyBody }) + ); await migrationRetryCallCluster(() => client.ping(), logger, 1); @@ -258,7 +271,9 @@ describe('migrationRetryCallCluster', () => { } as any) ) ) - .mockImplementationOnce(() => elasticsearchClientMock.createClientResponse({ ...dummyBody })); + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ ...dummyBody }) + ); await expect( migrationRetryCallCluster(() => client.ping(), logger, 1) @@ -274,7 +289,9 @@ describe('migrationRetryCallCluster', () => { createErrorReturn(new errors.TimeoutError('timeout error', {} as any)) ) .mockImplementationOnce(() => createErrorReturn(new Error('unknown error'))) - .mockImplementationOnce(() => elasticsearchClientMock.createClientResponse({ ...dummyBody })); + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ ...dummyBody }) + ); await expect( migrationRetryCallCluster(() => client.ping(), logger, 1) diff --git a/src/core/server/elasticsearch/client/retry_call_cluster.ts b/src/core/server/elasticsearch/client/retry_call_cluster.ts index 1ad039e512215..792f7f0a7fac9 100644 --- a/src/core/server/elasticsearch/client/retry_call_cluster.ts +++ b/src/core/server/elasticsearch/client/retry_call_cluster.ts @@ -27,7 +27,7 @@ const retryResponseStatuses = [ 403, // AuthenticationException 408, // RequestTimeout 410, // Gone -]; +] as const; /** * Retries the provided Elasticsearch API call when a `NoLivingConnectionsError` error is diff --git a/src/core/server/elasticsearch/client/types.ts b/src/core/server/elasticsearch/client/types.ts index 7ce998aab7669..285f52e89a591 100644 --- a/src/core/server/elasticsearch/client/types.ts +++ b/src/core/server/elasticsearch/client/types.ts @@ -41,3 +41,83 @@ export type ElasticsearchClient = Omit< ): TransportRequestPromise; }; }; + +interface ShardsResponse { + total: number; + successful: number; + failed: number; + skipped: number; +} + +interface Explanation { + value: number; + description: string; + details: Explanation[]; +} + +interface ShardsInfo { + total: number; + successful: number; + skipped: number; + failed: number; +} + +export interface CountResponse { + _shards: ShardsInfo; + count: number; +} + +/** + * Maintained until elasticsearch provides response typings out of the box + * https://github.com/elastic/elasticsearch-js/pull/970 + */ +export interface SearchResponse { + took: number; + timed_out: boolean; + _scroll_id?: string; + _shards: ShardsResponse; + hits: { + total: number; + max_score: number; + hits: Array<{ + _index: string; + _type: string; + _id: string; + _score: number; + _source: T; + _version?: number; + _explanation?: Explanation; + fields?: any; + highlight?: any; + inner_hits?: any; + matched_queries?: string[]; + sort?: string[]; + }>; + }; + aggregations?: any; +} + +export interface GetResponse { + _index: string; + _type: string; + _id: string; + _version: number; + _routing?: string; + found: boolean; + _source: T; + _seq_no: number; + _primary_term: number; +} + +export interface DeleteDocumentResponse { + _shards: ShardsResponse; + found: boolean; + _index: string; + _type: string; + _id: string; + _version: number; + result: string; + error?: { + type: string; + }; +} diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index 4375f09f1ce0b..49f5c8dd98790 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -227,7 +227,7 @@ describe('#setup', () => { it('esNodeVersionCompatibility$ only starts polling when subscribed to', async (done) => { const mockedClient = mockClusterClientInstance.asInternalUser; mockedClient.nodes.info.mockImplementation(() => - elasticsearchClientMock.createClientError(new Error()) + elasticsearchClientMock.createErrorTransportRequestPromise(new Error()) ); const setupContract = await elasticsearchService.setup(setupDeps); @@ -243,7 +243,7 @@ describe('#setup', () => { it('esNodeVersionCompatibility$ stops polling when unsubscribed from', async (done) => { const mockedClient = mockClusterClientInstance.asInternalUser; mockedClient.nodes.info.mockImplementation(() => - elasticsearchClientMock.createClientError(new Error()) + elasticsearchClientMock.createErrorTransportRequestPromise(new Error()) ); const setupContract = await elasticsearchService.setup(setupDeps); @@ -359,7 +359,7 @@ describe('#stop', () => { const mockedClient = mockClusterClientInstance.asInternalUser; mockedClient.nodes.info.mockImplementation(() => - elasticsearchClientMock.createClientError(new Error()) + elasticsearchClientMock.createErrorTransportRequestPromise(new Error()) ); const setupContract = await elasticsearchService.setup(setupDeps); diff --git a/src/core/server/elasticsearch/index.ts b/src/core/server/elasticsearch/index.ts index 8bb77b5dfdee0..32be6e6bf34dd 100644 --- a/src/core/server/elasticsearch/index.ts +++ b/src/core/server/elasticsearch/index.ts @@ -36,4 +36,8 @@ export { ElasticsearchClientConfig, ElasticsearchClient, IScopedClusterClient, + SearchResponse, + GetResponse, + DeleteDocumentResponse, + CountResponse, } from './client'; diff --git a/src/core/server/elasticsearch/legacy/index.ts b/src/core/server/elasticsearch/legacy/index.ts index 165980b9f4522..a1740faac7ddf 100644 --- a/src/core/server/elasticsearch/legacy/index.ts +++ b/src/core/server/elasticsearch/legacy/index.ts @@ -23,6 +23,5 @@ export { } from './cluster_client'; export { ILegacyScopedClusterClient, LegacyScopedClusterClient } from './scoped_cluster_client'; export { LegacyElasticsearchClientConfig } from './elasticsearch_client_config'; -export { retryCallCluster, migrationsRetryCallCluster } from './retry_call_cluster'; export { LegacyElasticsearchError, LegacyElasticsearchErrorHelpers } from './errors'; export * from './api_types'; diff --git a/src/core/server/elasticsearch/legacy/retry_call_cluster.test.ts b/src/core/server/elasticsearch/legacy/retry_call_cluster.test.ts deleted file mode 100644 index 62789a4fe952d..0000000000000 --- a/src/core/server/elasticsearch/legacy/retry_call_cluster.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import * as legacyElasticsearch from 'elasticsearch'; - -import { retryCallCluster, migrationsRetryCallCluster } from './retry_call_cluster'; -import { loggingSystemMock } from '../../logging/logging_system.mock'; - -describe('retryCallCluster', () => { - it('retries ES API calls that rejects with NoConnections', () => { - expect.assertions(1); - const callEsApi = jest.fn(); - let i = 0; - const ErrorConstructor = legacyElasticsearch.errors.NoConnections; - callEsApi.mockImplementation(() => { - return i++ <= 2 ? Promise.reject(new ErrorConstructor()) : Promise.resolve('success'); - }); - const retried = retryCallCluster(callEsApi); - return expect(retried('endpoint')).resolves.toMatchInlineSnapshot(`"success"`); - }); - - it('rejects when ES API calls reject with other errors', async () => { - expect.assertions(3); - const callEsApi = jest.fn(); - let i = 0; - callEsApi.mockImplementation(() => { - i++; - - return i === 1 - ? Promise.reject(new Error('unknown error')) - : i === 2 - ? Promise.resolve('success') - : i === 3 || i === 4 - ? Promise.reject(new legacyElasticsearch.errors.NoConnections()) - : i === 5 - ? Promise.reject(new Error('unknown error')) - : null; - }); - const retried = retryCallCluster(callEsApi); - await expect(retried('endpoint')).rejects.toMatchInlineSnapshot(`[Error: unknown error]`); - await expect(retried('endpoint')).resolves.toMatchInlineSnapshot(`"success"`); - return expect(retried('endpoint')).rejects.toMatchInlineSnapshot(`[Error: unknown error]`); - }); -}); - -describe('migrationsRetryCallCluster', () => { - const errors = [ - 'NoConnections', - 'ConnectionFault', - 'ServiceUnavailable', - 'RequestTimeout', - 'AuthenticationException', - 'AuthorizationException', - 'Gone', - ]; - - const mockLogger = loggingSystemMock.create(); - - beforeEach(() => { - loggingSystemMock.clear(mockLogger); - }); - - errors.forEach((errorName) => { - it('retries ES API calls that rejects with ' + errorName, () => { - expect.assertions(1); - const callEsApi = jest.fn(); - let i = 0; - const ErrorConstructor = (legacyElasticsearch.errors as any)[errorName]; - callEsApi.mockImplementation(() => { - return i++ <= 2 ? Promise.reject(new ErrorConstructor()) : Promise.resolve('success'); - }); - const retried = migrationsRetryCallCluster(callEsApi, mockLogger.get('mock log'), 1); - return expect(retried('endpoint')).resolves.toMatchInlineSnapshot(`"success"`); - }); - }); - - it('retries ES API calls that rejects with snapshot_in_progress_exception', () => { - expect.assertions(1); - const callEsApi = jest.fn(); - let i = 0; - callEsApi.mockImplementation(() => { - return i++ <= 2 - ? Promise.reject({ body: { error: { type: 'snapshot_in_progress_exception' } } }) - : Promise.resolve('success'); - }); - const retried = migrationsRetryCallCluster(callEsApi, mockLogger.get('mock log'), 1); - return expect(retried('endpoint')).resolves.toMatchInlineSnapshot(`"success"`); - }); - - it('rejects when ES API calls reject with other errors', async () => { - expect.assertions(3); - const callEsApi = jest.fn(); - let i = 0; - callEsApi.mockImplementation(() => { - i++; - - return i === 1 - ? Promise.reject(new Error('unknown error')) - : i === 2 - ? Promise.resolve('success') - : i === 3 || i === 4 - ? Promise.reject(new legacyElasticsearch.errors.NoConnections()) - : i === 5 - ? Promise.reject(new Error('unknown error')) - : null; - }); - const retried = migrationsRetryCallCluster(callEsApi, mockLogger.get('mock log'), 1); - await expect(retried('endpoint')).rejects.toMatchInlineSnapshot(`[Error: unknown error]`); - await expect(retried('endpoint')).resolves.toMatchInlineSnapshot(`"success"`); - return expect(retried('endpoint')).rejects.toMatchInlineSnapshot(`[Error: unknown error]`); - }); - - it('logs only once for each unique error message', async () => { - const callEsApi = jest.fn(); - callEsApi.mockRejectedValueOnce(new legacyElasticsearch.errors.NoConnections()); - callEsApi.mockRejectedValueOnce(new legacyElasticsearch.errors.NoConnections()); - callEsApi.mockRejectedValueOnce(new legacyElasticsearch.errors.AuthenticationException()); - callEsApi.mockResolvedValueOnce('done'); - const retried = migrationsRetryCallCluster(callEsApi, mockLogger.get('mock log'), 1); - await retried('endpoint'); - expect(loggingSystemMock.collect(mockLogger).warn).toMatchInlineSnapshot(` - Array [ - Array [ - "Unable to connect to Elasticsearch. Error: No Living connections", - ], - Array [ - "Unable to connect to Elasticsearch. Error: Authentication Exception", - ], - ] - `); - }); -}); diff --git a/src/core/server/elasticsearch/legacy/retry_call_cluster.ts b/src/core/server/elasticsearch/legacy/retry_call_cluster.ts deleted file mode 100644 index 1b05cb2bf13cd..0000000000000 --- a/src/core/server/elasticsearch/legacy/retry_call_cluster.ts +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { retryWhen, concatMap } from 'rxjs/operators'; -import { defer, throwError, iif, timer } from 'rxjs'; -import * as legacyElasticsearch from 'elasticsearch'; - -import { LegacyCallAPIOptions } from '.'; -import { LegacyAPICaller } from './api_types'; -import { Logger } from '../../logging'; - -const esErrors = legacyElasticsearch.errors; - -/** - * Retries the provided Elasticsearch API call when an error such as - * `AuthenticationException` `NoConnections`, `ConnectionFault`, - * `ServiceUnavailable` or `RequestTimeout` are encountered. The API call will - * be retried once a second, indefinitely, until a successful response or a - * different error is received. - * - * @param apiCaller - * @param log - * @param delay - */ -export function migrationsRetryCallCluster( - apiCaller: LegacyAPICaller, - log: Logger, - delay: number = 2500 -) { - const previousErrors: string[] = []; - return ( - endpoint: string, - clientParams: Record = {}, - options?: LegacyCallAPIOptions - ) => { - return defer(() => apiCaller(endpoint, clientParams, options)) - .pipe( - retryWhen((error$) => - error$.pipe( - concatMap((error) => { - if (!previousErrors.includes(error.message)) { - log.warn(`Unable to connect to Elasticsearch. Error: ${error.message}`); - previousErrors.push(error.message); - } - return iif( - () => { - return ( - error instanceof esErrors.NoConnections || - error instanceof esErrors.ConnectionFault || - error instanceof esErrors.ServiceUnavailable || - error instanceof esErrors.RequestTimeout || - error instanceof esErrors.AuthenticationException || - error instanceof esErrors.AuthorizationException || - // @ts-expect-error - error instanceof esErrors.Gone || - error?.body?.error?.type === 'snapshot_in_progress_exception' - ); - }, - timer(delay), - throwError(error) - ); - }) - ) - ) - ) - .toPromise(); - }; -} - -/** - * Retries the provided Elasticsearch API call when a `NoConnections` error is - * encountered. The API call will be retried once a second, indefinitely, until - * a successful response or a different error is received. - * - * @param apiCaller - */ -export function retryCallCluster(apiCaller: LegacyAPICaller) { - return ( - endpoint: string, - clientParams: Record = {}, - options?: LegacyCallAPIOptions - ) => { - return defer(() => apiCaller(endpoint, clientParams, options)) - .pipe( - retryWhen((errors) => - errors.pipe( - concatMap((error) => - iif( - () => error instanceof legacyElasticsearch.errors.NoConnections, - timer(1000), - throwError(error) - ) - ) - ) - ) - ) - .toPromise(); - }; -} diff --git a/src/core/server/elasticsearch/version_check/ensure_es_version.test.ts b/src/core/server/elasticsearch/version_check/ensure_es_version.test.ts index 21adac081acf7..f6313f68abff2 100644 --- a/src/core/server/elasticsearch/version_check/ensure_es_version.test.ts +++ b/src/core/server/elasticsearch/version_check/ensure_es_version.test.ts @@ -28,8 +28,8 @@ const mockLogger = mockLoggerFactory.get('mock logger'); const KIBANA_VERSION = '5.1.0'; -const createEsSuccess = elasticsearchClientMock.createClientResponse; -const createEsError = elasticsearchClientMock.createClientError; +const createEsSuccess = elasticsearchClientMock.createSuccessTransportRequestPromise; +const createEsError = elasticsearchClientMock.createErrorTransportRequestPromise; function createNodes(...versions: string[]): NodesInfo { const nodes = {} as any; diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index 6338326626d54..6a00db5a6cc4a 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -479,7 +479,7 @@ describe('http service', () => { let elasticsearch: InternalElasticsearchServiceStart; esClient.ping.mockImplementation(() => - elasticsearchClientMock.createClientError( + elasticsearchClientMock.createErrorTransportRequestPromise( new ResponseError({ statusCode: 401, body: { @@ -517,7 +517,7 @@ describe('http service', () => { let elasticsearch: InternalElasticsearchServiceStart; esClient.ping.mockImplementation(() => - elasticsearchClientMock.createClientError( + elasticsearchClientMock.createErrorTransportRequestPromise( new ResponseError({ statusCode: 401, body: { diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 706ec88c6ebfd..c846e81573acb 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -109,6 +109,7 @@ export { LegacyAPICaller, FakeRequest, ScopeableRequest, + ElasticsearchClient, } from './elasticsearch'; export * from './elasticsearch/legacy/api_types'; export { diff --git a/src/core/server/saved_objects/migrations/core/__snapshots__/elastic_index.test.ts.snap b/src/core/server/saved_objects/migrations/core/__snapshots__/elastic_index.test.ts.snap index 76bcc6ee219d9..6bd567be204d0 100644 --- a/src/core/server/saved_objects/migrations/core/__snapshots__/elastic_index.test.ts.snap +++ b/src/core/server/saved_objects/migrations/core/__snapshots__/elastic_index.test.ts.snap @@ -2,7 +2,6 @@ exports[`ElasticIndex write writes documents in bulk to the index 1`] = ` Array [ - "bulk", Object { "body": Array [ Object { diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts index 393cbb7fbb2ae..fb8fb4ef95081 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts @@ -18,47 +18,52 @@ */ import _ from 'lodash'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import * as Index from './elastic_index'; describe('ElasticIndex', () => { + let client: ReturnType; + + beforeEach(() => { + client = elasticsearchClientMock.createElasticSearchClient(); + }); describe('fetchInfo', () => { test('it handles 404', async () => { - const callCluster = jest - .fn() - .mockImplementation(async (path: string, { ignore, index }: any) => { - expect(path).toEqual('indices.get'); - expect(ignore).toEqual([404]); - expect(index).toEqual('.kibana-test'); - return { status: 404 }; - }); + client.indices.get.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); - const info = await Index.fetchInfo(callCluster as any, '.kibana-test'); + const info = await Index.fetchInfo(client, '.kibana-test'); expect(info).toEqual({ aliases: {}, exists: false, indexName: '.kibana-test', mappings: { dynamic: 'strict', properties: {} }, }); + + expect(client.indices.get).toHaveBeenCalledWith({ index: '.kibana-test' }, { ignore: [404] }); }); test('fails if the index doc type is unsupported', async () => { - const callCluster = jest.fn(async (path: string, { index }: any) => { - return { + client.indices.get.mockImplementation((params) => { + const index = params!.index as string; + return elasticsearchClientMock.createSuccessTransportRequestPromise({ [index]: { aliases: { foo: index }, mappings: { spock: { dynamic: 'strict', properties: { a: 'b' } } }, }, - }; + }); }); - await expect(Index.fetchInfo(callCluster as any, '.baz')).rejects.toThrow( + await expect(Index.fetchInfo(client, '.baz')).rejects.toThrow( /cannot be automatically migrated/ ); }); test('fails if there are multiple root types', async () => { - const callCluster = jest.fn().mockImplementation(async (path: string, { index }: any) => { - return { + client.indices.get.mockImplementation((params) => { + const index = params!.index as string; + return elasticsearchClientMock.createSuccessTransportRequestPromise({ [index]: { aliases: { foo: index }, mappings: { @@ -66,25 +71,26 @@ describe('ElasticIndex', () => { doctor: { dynamic: 'strict', properties: { a: 'b' } }, }, }, - }; + }); }); - await expect(Index.fetchInfo(callCluster, '.baz')).rejects.toThrow( + await expect(Index.fetchInfo(client, '.baz')).rejects.toThrow( /cannot be automatically migrated/ ); }); test('decorates index info with exists and indexName', async () => { - const callCluster = jest.fn().mockImplementation(async (path: string, { index }: any) => { - return { + client.indices.get.mockImplementation((params) => { + const index = params!.index as string; + return elasticsearchClientMock.createSuccessTransportRequestPromise({ [index]: { aliases: { foo: index }, mappings: { dynamic: 'strict', properties: { a: 'b' } }, }, - }; + }); }); - const info = await Index.fetchInfo(callCluster, '.baz'); + const info = await Index.fetchInfo(client, '.baz'); expect(info).toEqual({ aliases: { foo: '.baz' }, mappings: { dynamic: 'strict', properties: { a: 'b' } }, @@ -96,171 +102,120 @@ describe('ElasticIndex', () => { describe('createIndex', () => { test('calls indices.create', async () => { - const callCluster = jest.fn(async (path: string, { body, index }: any) => { - expect(path).toEqual('indices.create'); - expect(body).toEqual({ + await Index.createIndex(client, '.abcd', { foo: 'bar' } as any); + + expect(client.indices.create).toHaveBeenCalledTimes(1); + expect(client.indices.create).toHaveBeenCalledWith({ + body: { mappings: { foo: 'bar' }, - settings: { auto_expand_replicas: '0-1', number_of_shards: 1 }, - }); - expect(index).toEqual('.abcd'); + settings: { + auto_expand_replicas: '0-1', + number_of_shards: 1, + }, + }, + index: '.abcd', }); - - await Index.createIndex(callCluster as any, '.abcd', { foo: 'bar' } as any); - expect(callCluster).toHaveBeenCalled(); }); }); describe('deleteIndex', () => { test('calls indices.delete', async () => { - const callCluster = jest.fn(async (path: string, { index }: any) => { - expect(path).toEqual('indices.delete'); - expect(index).toEqual('.lotr'); - }); + await Index.deleteIndex(client, '.lotr'); - await Index.deleteIndex(callCluster as any, '.lotr'); - expect(callCluster).toHaveBeenCalled(); + expect(client.indices.delete).toHaveBeenCalledTimes(1); + expect(client.indices.delete).toHaveBeenCalledWith({ + index: '.lotr', + }); }); }); describe('claimAlias', () => { - function assertCalled(callCluster: jest.Mock) { - expect(callCluster.mock.calls.map(([path]) => path)).toEqual([ - 'indices.getAlias', - 'indices.updateAliases', - 'indices.refresh', - ]); - } - test('handles unaliased indices', async () => { - const callCluster = jest.fn(async (path: string, arg: any) => { - switch (path) { - case 'indices.getAlias': - expect(arg.ignore).toEqual([404]); - expect(arg.name).toEqual('.hola'); - return { status: 404 }; - case 'indices.updateAliases': - expect(arg.body).toEqual({ - actions: [{ add: { index: '.hola-42', alias: '.hola' } }], - }); - return true; - case 'indices.refresh': - expect(arg.index).toEqual('.hola-42'); - return true; - default: - throw new Error(`Dunnoes what ${path} means.`); - } - }); + client.indices.getAlias.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); - await Index.claimAlias(callCluster as any, '.hola-42', '.hola'); + await Index.claimAlias(client, '.hola-42', '.hola'); - assertCalled(callCluster); + expect(client.indices.getAlias).toHaveBeenCalledWith( + { + name: '.hola', + }, + { ignore: [404] } + ); + expect(client.indices.updateAliases).toHaveBeenCalledWith({ + body: { + actions: [{ add: { index: '.hola-42', alias: '.hola' } }], + }, + }); + expect(client.indices.refresh).toHaveBeenCalledWith({ + index: '.hola-42', + }); }); test('removes existing alias', async () => { - const callCluster = jest.fn(async (path: string, arg: any) => { - switch (path) { - case 'indices.getAlias': - return { '.my-fanci-index': '.muchacha' }; - case 'indices.updateAliases': - expect(arg.body).toEqual({ - actions: [ - { remove: { index: '.my-fanci-index', alias: '.muchacha' } }, - { add: { index: '.ze-index', alias: '.muchacha' } }, - ], - }); - return true; - case 'indices.refresh': - expect(arg.index).toEqual('.ze-index'); - return true; - default: - throw new Error(`Dunnoes what ${path} means.`); - } - }); + client.indices.getAlias.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + '.my-fanci-index': '.muchacha', + }) + ); - await Index.claimAlias(callCluster as any, '.ze-index', '.muchacha'); + await Index.claimAlias(client, '.ze-index', '.muchacha'); - assertCalled(callCluster); + expect(client.indices.getAlias).toHaveBeenCalledTimes(1); + expect(client.indices.updateAliases).toHaveBeenCalledWith({ + body: { + actions: [ + { remove: { index: '.my-fanci-index', alias: '.muchacha' } }, + { add: { index: '.ze-index', alias: '.muchacha' } }, + ], + }, + }); + expect(client.indices.refresh).toHaveBeenCalledWith({ + index: '.ze-index', + }); }); test('allows custom alias actions', async () => { - const callCluster = jest.fn(async (path: string, arg: any) => { - switch (path) { - case 'indices.getAlias': - return { '.my-fanci-index': '.muchacha' }; - case 'indices.updateAliases': - expect(arg.body).toEqual({ - actions: [ - { remove_index: { index: 'awww-snap!' } }, - { remove: { index: '.my-fanci-index', alias: '.muchacha' } }, - { add: { index: '.ze-index', alias: '.muchacha' } }, - ], - }); - return true; - case 'indices.refresh': - expect(arg.index).toEqual('.ze-index'); - return true; - default: - throw new Error(`Dunnoes what ${path} means.`); - } - }); + client.indices.getAlias.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + '.my-fanci-index': '.muchacha', + }) + ); - await Index.claimAlias(callCluster as any, '.ze-index', '.muchacha', [ + await Index.claimAlias(client, '.ze-index', '.muchacha', [ { remove_index: { index: 'awww-snap!' } }, ]); - assertCalled(callCluster); + expect(client.indices.getAlias).toHaveBeenCalledTimes(1); + expect(client.indices.updateAliases).toHaveBeenCalledWith({ + body: { + actions: [ + { remove_index: { index: 'awww-snap!' } }, + { remove: { index: '.my-fanci-index', alias: '.muchacha' } }, + { add: { index: '.ze-index', alias: '.muchacha' } }, + ], + }, + }); + expect(client.indices.refresh).toHaveBeenCalledWith({ + index: '.ze-index', + }); }); }); describe('convertToAlias', () => { test('it creates the destination index, then reindexes to it', async () => { - const callCluster = jest.fn(async (path: string, arg: any) => { - switch (path) { - case 'indices.create': - expect(arg.body).toEqual({ - mappings: { - dynamic: 'strict', - properties: { foo: { type: 'keyword' } }, - }, - settings: { auto_expand_replicas: '0-1', number_of_shards: 1 }, - }); - expect(arg.index).toEqual('.ze-index'); - return true; - case 'reindex': - expect(arg).toMatchObject({ - body: { - dest: { index: '.ze-index' }, - source: { index: '.muchacha' }, - script: { - source: `ctx._id = ctx._source.type + ':' + ctx._id`, - lang: 'painless', - }, - }, - refresh: true, - waitForCompletion: false, - }); - return { task: 'abc' }; - case 'tasks.get': - expect(arg.taskId).toEqual('abc'); - return { completed: true }; - case 'indices.getAlias': - return { '.my-fanci-index': '.muchacha' }; - case 'indices.updateAliases': - expect(arg.body).toEqual({ - actions: [ - { remove_index: { index: '.muchacha' } }, - { remove: { alias: '.muchacha', index: '.my-fanci-index' } }, - { add: { index: '.ze-index', alias: '.muchacha' } }, - ], - }); - return true; - case 'indices.refresh': - expect(arg.index).toEqual('.ze-index'); - return true; - default: - throw new Error(`Dunnoes what ${path} means.`); - } - }); + client.indices.getAlias.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + '.my-fanci-index': '.muchacha', + }) + ); + client.reindex.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ task: 'abc' }) + ); + client.tasks.get.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ completed: true }) + ); const info = { aliases: {}, @@ -271,61 +226,77 @@ describe('ElasticIndex', () => { properties: { foo: { type: 'keyword' } }, }, }; + await Index.convertToAlias( - callCluster as any, + client, info, '.muchacha', 10, `ctx._id = ctx._source.type + ':' + ctx._id` ); - expect(callCluster.mock.calls.map(([path]) => path)).toEqual([ - 'indices.create', - 'reindex', - 'tasks.get', - 'indices.getAlias', - 'indices.updateAliases', - 'indices.refresh', - ]); + expect(client.indices.create).toHaveBeenCalledWith({ + body: { + mappings: { + dynamic: 'strict', + properties: { foo: { type: 'keyword' } }, + }, + settings: { auto_expand_replicas: '0-1', number_of_shards: 1 }, + }, + index: '.ze-index', + }); + + expect(client.reindex).toHaveBeenCalledWith({ + body: { + dest: { index: '.ze-index' }, + source: { index: '.muchacha', size: 10 }, + script: { + source: `ctx._id = ctx._source.type + ':' + ctx._id`, + lang: 'painless', + }, + }, + refresh: true, + wait_for_completion: false, + }); + + expect(client.tasks.get).toHaveBeenCalledWith({ + task_id: 'abc', + }); + + expect(client.indices.updateAliases).toHaveBeenCalledWith({ + body: { + actions: [ + { remove_index: { index: '.muchacha' } }, + { remove: { alias: '.muchacha', index: '.my-fanci-index' } }, + { add: { index: '.ze-index', alias: '.muchacha' } }, + ], + }, + }); + + expect(client.indices.refresh).toHaveBeenCalledWith({ + index: '.ze-index', + }); }); test('throws error if re-index task fails', async () => { - const callCluster = jest.fn(async (path: string, arg: any) => { - switch (path) { - case 'indices.create': - expect(arg.body).toEqual({ - mappings: { - dynamic: 'strict', - properties: { foo: { type: 'keyword' } }, - }, - settings: { auto_expand_replicas: '0-1', number_of_shards: 1 }, - }); - expect(arg.index).toEqual('.ze-index'); - return true; - case 'reindex': - expect(arg).toMatchObject({ - body: { - dest: { index: '.ze-index' }, - source: { index: '.muchacha' }, - }, - refresh: true, - waitForCompletion: false, - }); - return { task: 'abc' }; - case 'tasks.get': - expect(arg.taskId).toEqual('abc'); - return { - completed: true, - error: { - type: 'search_phase_execution_exception', - reason: 'all shards failed', - failed_shards: [], - }, - }; - default: - throw new Error(`Dunnoes what ${path} means.`); - } - }); + client.indices.getAlias.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + '.my-fanci-index': '.muchacha', + }) + ); + client.reindex.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ task: 'abc' }) + ); + client.tasks.get.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + completed: true, + error: { + type: 'search_phase_execution_exception', + reason: 'all shards failed', + failed_shards: [], + }, + }) + ); const info = { aliases: {}, @@ -336,22 +307,44 @@ describe('ElasticIndex', () => { properties: { foo: { type: 'keyword' } }, }, }; - await expect(Index.convertToAlias(callCluster as any, info, '.muchacha', 10)).rejects.toThrow( + + await expect(Index.convertToAlias(client, info, '.muchacha', 10)).rejects.toThrow( /Re-index failed \[search_phase_execution_exception\] all shards failed/ ); - expect(callCluster.mock.calls.map(([path]) => path)).toEqual([ - 'indices.create', - 'reindex', - 'tasks.get', - ]); + expect(client.indices.create).toHaveBeenCalledWith({ + body: { + mappings: { + dynamic: 'strict', + properties: { foo: { type: 'keyword' } }, + }, + settings: { auto_expand_replicas: '0-1', number_of_shards: 1 }, + }, + index: '.ze-index', + }); + + expect(client.reindex).toHaveBeenCalledWith({ + body: { + dest: { index: '.ze-index' }, + source: { index: '.muchacha', size: 10 }, + }, + refresh: true, + wait_for_completion: false, + }); + + expect(client.tasks.get).toHaveBeenCalledWith({ + task_id: 'abc', + }); }); }); describe('write', () => { test('writes documents in bulk to the index', async () => { + client.bulk.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ items: [] }) + ); + const index = '.myalias'; - const callCluster = jest.fn().mockResolvedValue({ items: [] }); const docs = [ { _id: 'niceguy:fredrogers', @@ -375,19 +368,20 @@ describe('ElasticIndex', () => { }, ]; - await Index.write(callCluster, index, docs); + await Index.write(client, index, docs); - expect(callCluster).toHaveBeenCalled(); - expect(callCluster.mock.calls[0]).toMatchSnapshot(); + expect(client.bulk).toHaveBeenCalled(); + expect(client.bulk.mock.calls[0]).toMatchSnapshot(); }); test('fails if any document fails', async () => { - const index = '.myalias'; - const callCluster = jest.fn(() => - Promise.resolve({ + client.bulk.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ items: [{ index: { error: { type: 'shazm', reason: 'dern' } } }], }) ); + + const index = '.myalias'; const docs = [ { _id: 'niceguy:fredrogers', @@ -400,23 +394,20 @@ describe('ElasticIndex', () => { }, ]; - await expect(Index.write(callCluster as any, index, docs)).rejects.toThrow(/dern/); - expect(callCluster).toHaveBeenCalled(); + await expect(Index.write(client as any, index, docs)).rejects.toThrow(/dern/); + expect(client.bulk).toHaveBeenCalledTimes(1); }); }); describe('reader', () => { test('returns docs in batches', async () => { const index = '.myalias'; - const callCluster = jest.fn(); - const batch1 = [ { _id: 'such:1', _source: { type: 'such', such: { num: 1 } }, }, ]; - const batch2 = [ { _id: 'aaa:2', @@ -432,42 +423,56 @@ describe('ElasticIndex', () => { }, ]; - callCluster - .mockResolvedValueOnce({ + client.search = jest.fn().mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _scroll_id: 'x', _shards: { success: 1, total: 1 }, hits: { hits: _.cloneDeep(batch1) }, }) - .mockResolvedValueOnce({ - _scroll_id: 'y', - _shards: { success: 1, total: 1 }, - hits: { hits: _.cloneDeep(batch2) }, - }) - .mockResolvedValueOnce({ - _scroll_id: 'z', - _shards: { success: 1, total: 1 }, - hits: { hits: [] }, - }) - .mockResolvedValue({}); - - const read = Index.reader(callCluster, index, { batchSize: 100, scrollDuration: '5m' }); + ); + client.scroll = jest + .fn() + .mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _scroll_id: 'y', + _shards: { success: 1, total: 1 }, + hits: { hits: _.cloneDeep(batch2) }, + }) + ) + .mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _scroll_id: 'z', + _shards: { success: 1, total: 1 }, + hits: { hits: [] }, + }) + ); + + const read = Index.reader(client, index, { batchSize: 100, scrollDuration: '5m' }); expect(await read()).toEqual(batch1); expect(await read()).toEqual(batch2); expect(await read()).toEqual([]); - // Check order of calls, as well as args - expect(callCluster.mock.calls).toEqual([ - ['search', { body: { size: 100 }, index, scroll: '5m' }], - ['scroll', { scroll: '5m', scrollId: 'x' }], - ['scroll', { scroll: '5m', scrollId: 'y' }], - ['clearScroll', { scrollId: 'z' }], - ]); + expect(client.search).toHaveBeenCalledWith({ + body: { size: 100 }, + index, + scroll: '5m', + }); + expect(client.scroll).toHaveBeenCalledWith({ + scroll: '5m', + scroll_id: 'x', + }); + expect(client.scroll).toHaveBeenCalledWith({ + scroll: '5m', + scroll_id: 'y', + }); + expect(client.clearScroll).toHaveBeenCalledWith({ + scroll_id: 'z', + }); }); test('returns all root-level properties', async () => { const index = '.myalias'; - const callCluster = jest.fn(); const batch = [ { _id: 'such:1', @@ -480,19 +485,22 @@ describe('ElasticIndex', () => { }, ]; - callCluster - .mockResolvedValueOnce({ + client.search = jest.fn().mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _scroll_id: 'x', _shards: { success: 1, total: 1 }, hits: { hits: _.cloneDeep(batch) }, }) - .mockResolvedValue({ + ); + client.scroll = jest.fn().mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _scroll_id: 'z', _shards: { success: 1, total: 1 }, hits: { hits: [] }, - }); + }) + ); - const read = Index.reader(callCluster, index, { + const read = Index.reader(client, index, { batchSize: 100, scrollDuration: '5m', }); @@ -502,11 +510,14 @@ describe('ElasticIndex', () => { test('fails if not all shards were successful', async () => { const index = '.myalias'; - const callCluster = jest.fn(); - callCluster.mockResolvedValue({ _shards: { successful: 1, total: 2 } }); + client.search = jest.fn().mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _shards: { successful: 1, total: 2 }, + }) + ); - const read = Index.reader(callCluster, index, { + const read = Index.reader(client, index, { batchSize: 100, scrollDuration: '5m', }); @@ -516,7 +527,6 @@ describe('ElasticIndex', () => { test('handles shards not being returned', async () => { const index = '.myalias'; - const callCluster = jest.fn(); const batch = [ { _id: 'such:1', @@ -529,11 +539,20 @@ describe('ElasticIndex', () => { }, ]; - callCluster - .mockResolvedValueOnce({ _scroll_id: 'x', hits: { hits: _.cloneDeep(batch) } }) - .mockResolvedValue({ _scroll_id: 'z', hits: { hits: [] } }); + client.search = jest.fn().mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _scroll_id: 'x', + hits: { hits: _.cloneDeep(batch) }, + }) + ); + client.scroll = jest.fn().mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _scroll_id: 'z', + hits: { hits: [] }, + }) + ); - const read = Index.reader(callCluster, index, { + const read = Index.reader(client, index, { batchSize: 100, scrollDuration: '5m', }); @@ -550,23 +569,24 @@ describe('ElasticIndex', () => { count, migrations, }: any) { - const callCluster = jest.fn(async (path: string) => { - if (path === 'indices.get') { - return { - [index]: { mappings }, - }; - } - if (path === 'count') { - return { count, _shards: { success: 1, total: 1 } }; - } - throw new Error(`Unknown command ${path}.`); - }); - const hasMigrations = await Index.migrationsUpToDate(callCluster as any, index, migrations); - return { hasMigrations, callCluster }; + client.indices.get = jest.fn().mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + [index]: { mappings }, + }) + ); + client.count = jest.fn().mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + count, + _shards: { success: 1, total: 1 }, + }) + ); + + const hasMigrations = await Index.migrationsUpToDate(client, index, migrations); + return { hasMigrations }; } test('is false if the index mappings do not contain migrationVersion', async () => { - const { hasMigrations, callCluster } = await testMigrationsUpToDate({ + const { hasMigrations } = await testMigrationsUpToDate({ index: '.myalias', mappings: { properties: { @@ -578,17 +598,18 @@ describe('ElasticIndex', () => { }); expect(hasMigrations).toBeFalsy(); - expect(callCluster.mock.calls[0]).toEqual([ - 'indices.get', + expect(client.indices.get).toHaveBeenCalledWith( { - ignore: [404], index: '.myalias', }, - ]); + { + ignore: [404], + } + ); }); test('is true if there are no migrations defined', async () => { - const { hasMigrations, callCluster } = await testMigrationsUpToDate({ + const { hasMigrations } = await testMigrationsUpToDate({ index: '.myalias', mappings: { properties: { @@ -604,12 +625,11 @@ describe('ElasticIndex', () => { }); expect(hasMigrations).toBeTruthy(); - expect(callCluster).toHaveBeenCalled(); - expect(callCluster.mock.calls[0][0]).toEqual('indices.get'); + expect(client.indices.get).toHaveBeenCalledTimes(1); }); test('is true if there are no documents out of date', async () => { - const { hasMigrations, callCluster } = await testMigrationsUpToDate({ + const { hasMigrations } = await testMigrationsUpToDate({ index: '.myalias', mappings: { properties: { @@ -625,13 +645,12 @@ describe('ElasticIndex', () => { }); expect(hasMigrations).toBeTruthy(); - expect(callCluster).toHaveBeenCalled(); - expect(callCluster.mock.calls[0][0]).toEqual('indices.get'); - expect(callCluster.mock.calls[1][0]).toEqual('count'); + expect(client.indices.get).toHaveBeenCalledTimes(1); + expect(client.count).toHaveBeenCalledTimes(1); }); test('is false if there are documents out of date', async () => { - const { hasMigrations, callCluster } = await testMigrationsUpToDate({ + const { hasMigrations } = await testMigrationsUpToDate({ index: '.myalias', mappings: { properties: { @@ -647,12 +666,12 @@ describe('ElasticIndex', () => { }); expect(hasMigrations).toBeFalsy(); - expect(callCluster.mock.calls[0][0]).toEqual('indices.get'); - expect(callCluster.mock.calls[1][0]).toEqual('count'); + expect(client.indices.get).toHaveBeenCalledTimes(1); + expect(client.count).toHaveBeenCalledTimes(1); }); test('counts docs that are out of date', async () => { - const { callCluster } = await testMigrationsUpToDate({ + await testMigrationsUpToDate({ index: '.myalias', mappings: { properties: { @@ -686,23 +705,20 @@ describe('ElasticIndex', () => { }; } - expect(callCluster.mock.calls[1]).toEqual([ - 'count', - { - body: { - query: { - bool: { - should: [ - shouldClause('dashy', '23.2.5'), - shouldClause('bashy', '99.9.3'), - shouldClause('flashy', '3.4.5'), - ], - }, + expect(client.count).toHaveBeenCalledWith({ + body: { + query: { + bool: { + should: [ + shouldClause('dashy', '23.2.5'), + shouldClause('bashy', '99.9.3'), + shouldClause('flashy', '3.4.5'), + ], }, }, - index: '.myalias', }, - ]); + index: '.myalias', + }); }); }); }); diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.ts b/src/core/server/saved_objects/migrations/core/elastic_index.ts index e87c3e3ff0d64..d5093bfd8dc42 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.ts @@ -23,9 +23,12 @@ */ import _ from 'lodash'; +import { MigrationEsClient } from './migration_es_client'; +import { CountResponse, SearchResponse } from '../../../elasticsearch'; import { IndexMapping } from '../../mappings'; import { SavedObjectsMigrationVersion } from '../../types'; -import { AliasAction, CallCluster, NotFound, RawDoc, ShardsInfo } from './call_cluster'; +import { AliasAction, RawDoc, ShardsInfo } from './call_cluster'; +import { SavedObjectsRawDocSource } from '../../serialization'; const settings = { number_of_shards: 1, auto_expand_replicas: '0-1' }; @@ -40,13 +43,10 @@ export interface FullIndexInfo { * A slight enhancement to indices.get, that adds indexName, and validates that the * index mappings are somewhat what we expect. */ -export async function fetchInfo(callCluster: CallCluster, index: string): Promise { - const result = await callCluster('indices.get', { - ignore: [404], - index, - }); +export async function fetchInfo(client: MigrationEsClient, index: string): Promise { + const { body, statusCode } = await client.indices.get({ index }, { ignore: [404] }); - if ((result as NotFound).status === 404) { + if (statusCode === 404) { return { aliases: {}, exists: false, @@ -55,7 +55,7 @@ export async function fetchInfo(callCluster: CallCluster, index: string): Promis }; } - const [indexName, indexInfo] = Object.entries(result)[0]; + const [indexName, indexInfo] = Object.entries(body)[0]; return assertIsSupportedIndex({ ...indexInfo, exists: true, indexName }); } @@ -71,7 +71,7 @@ export async function fetchInfo(callCluster: CallCluster, index: string): Promis * @prop {string} scrollDuration - The scroll duration used for scrolling through the index */ export function reader( - callCluster: CallCluster, + client: MigrationEsClient, index: string, { batchSize = 10, scrollDuration = '15m' }: { batchSize: number; scrollDuration: string } ) { @@ -80,19 +80,24 @@ export function reader( const nextBatch = () => scrollId !== undefined - ? callCluster('scroll', { scroll, scrollId }) - : callCluster('search', { body: { size: batchSize }, index, scroll }); - - const close = async () => scrollId && (await callCluster('clearScroll', { scrollId })); + ? client.scroll>({ + scroll, + scroll_id: scrollId, + }) + : client.search>({ + body: { size: batchSize }, + index, + scroll, + }); + + const close = async () => scrollId && (await client.clearScroll({ scroll_id: scrollId })); return async function read() { const result = await nextBatch(); - assertResponseIncludeAllShards(result); - - const docs = result.hits.hits; - - scrollId = result._scroll_id; + assertResponseIncludeAllShards(result.body); + scrollId = result.body._scroll_id; + const docs = result.body.hits.hits; if (!docs.length) { await close(); } @@ -109,8 +114,8 @@ export function reader( * @param {string} index * @param {RawDoc[]} docs */ -export async function write(callCluster: CallCluster, index: string, docs: RawDoc[]) { - const result = await callCluster('bulk', { +export async function write(client: MigrationEsClient, index: string, docs: RawDoc[]) { + const { body } = await client.bulk({ body: docs.reduce((acc: object[], doc: RawDoc) => { acc.push({ index: { @@ -125,7 +130,7 @@ export async function write(callCluster: CallCluster, index: string, docs: RawDo }, []), }); - const err = _.find(result.items, 'index.error.reason'); + const err = _.find(body.items, 'index.error.reason'); if (!err) { return; @@ -150,15 +155,15 @@ export async function write(callCluster: CallCluster, index: string, docs: RawDo * @param {SavedObjectsMigrationVersion} migrationVersion - The latest versions of the migrations */ export async function migrationsUpToDate( - callCluster: CallCluster, + client: MigrationEsClient, index: string, migrationVersion: SavedObjectsMigrationVersion, retryCount: number = 10 ): Promise { try { - const indexInfo = await fetchInfo(callCluster, index); + const indexInfo = await fetchInfo(client, index); - if (!_.get(indexInfo, 'mappings.properties.migrationVersion')) { + if (!indexInfo.mappings.properties?.migrationVersion) { return false; } @@ -167,7 +172,7 @@ export async function migrationsUpToDate( return true; } - const response = await callCluster('count', { + const { body } = await client.count({ body: { query: { bool: { @@ -175,7 +180,11 @@ export async function migrationsUpToDate( bool: { must: [ { exists: { field: type } }, - { bool: { must_not: { term: { [`migrationVersion.${type}`]: latestVersion } } } }, + { + bool: { + must_not: { term: { [`migrationVersion.${type}`]: latestVersion } }, + }, + }, ], }, })), @@ -185,9 +194,9 @@ export async function migrationsUpToDate( index, }); - assertResponseIncludeAllShards(response); + assertResponseIncludeAllShards(body); - return response.count === 0; + return body.count === 0; } catch (e) { // retry for Service Unavailable if (e.status !== 503 || retryCount === 0) { @@ -196,23 +205,23 @@ export async function migrationsUpToDate( await new Promise((r) => setTimeout(r, 1000)); - return await migrationsUpToDate(callCluster, index, migrationVersion, retryCount - 1); + return await migrationsUpToDate(client, index, migrationVersion, retryCount - 1); } } export async function createIndex( - callCluster: CallCluster, + client: MigrationEsClient, index: string, mappings?: IndexMapping ) { - await callCluster('indices.create', { + await client.indices.create({ body: { mappings, settings }, index, }); } -export async function deleteIndex(callCluster: CallCluster, index: string) { - await callCluster('indices.delete', { index }); +export async function deleteIndex(client: MigrationEsClient, index: string) { + await client.indices.delete({ index }); } /** @@ -225,20 +234,20 @@ export async function deleteIndex(callCluster: CallCluster, index: string) { * @param {string} alias - The name of the index being converted to an alias */ export async function convertToAlias( - callCluster: CallCluster, + client: MigrationEsClient, info: FullIndexInfo, alias: string, batchSize: number, script?: string ) { - await callCluster('indices.create', { + await client.indices.create({ body: { mappings: info.mappings, settings }, index: info.indexName, }); - await reindex(callCluster, alias, info.indexName, batchSize, script); + await reindex(client, alias, info.indexName, batchSize, script); - await claimAlias(callCluster, info.indexName, alias, [{ remove_index: { index: alias } }]); + await claimAlias(client, info.indexName, alias, [{ remove_index: { index: alias } }]); } /** @@ -252,22 +261,22 @@ export async function convertToAlias( * @param {AliasAction[]} aliasActions - Optional actions to be added to the updateAliases call */ export async function claimAlias( - callCluster: CallCluster, + client: MigrationEsClient, index: string, alias: string, aliasActions: AliasAction[] = [] ) { - const result = await callCluster('indices.getAlias', { ignore: [404], name: alias }); - const aliasInfo = (result as NotFound).status === 404 ? {} : result; + const { body, statusCode } = await client.indices.getAlias({ name: alias }, { ignore: [404] }); + const aliasInfo = statusCode === 404 ? {} : body; const removeActions = Object.keys(aliasInfo).map((key) => ({ remove: { index: key, alias } })); - await callCluster('indices.updateAliases', { + await client.indices.updateAliases({ body: { actions: aliasActions.concat(removeActions).concat({ add: { index, alias } }), }, }); - await callCluster('indices.refresh', { index }); + await client.indices.refresh({ index }); } /** @@ -318,7 +327,7 @@ function assertResponseIncludeAllShards({ _shards }: { _shards: ShardsInfo }) { * Reindexes from source to dest, polling for the reindex completion. */ async function reindex( - callCluster: CallCluster, + client: MigrationEsClient, source: string, dest: string, batchSize: number, @@ -329,7 +338,7 @@ async function reindex( // polling interval, as the request is fairly efficent, and we don't // want to block index migrations for too long on this. const pollInterval = 250; - const { task } = await callCluster('reindex', { + const { body: reindexBody } = await client.reindex({ body: { dest: { index: dest }, source: { index: source, size: batchSize }, @@ -341,23 +350,25 @@ async function reindex( : undefined, }, refresh: true, - waitForCompletion: false, + wait_for_completion: false, }); + const task = reindexBody.task; + let completed = false; while (!completed) { await new Promise((r) => setTimeout(r, pollInterval)); - completed = await callCluster('tasks.get', { - taskId: task, - }).then((result) => { - if (result.error) { - const e = result.error; - throw new Error(`Re-index failed [${e.type}] ${e.reason} :: ${JSON.stringify(e)}`); - } - - return result.completed; + const { body } = await client.tasks.get({ + task_id: task, }); + + if (body.error) { + const e = body.error; + throw new Error(`Re-index failed [${e.type}] ${e.reason} :: ${JSON.stringify(e)}`); + } + + completed = body.completed; } } diff --git a/src/core/server/saved_objects/migrations/core/index.ts b/src/core/server/saved_objects/migrations/core/index.ts index f7274740ea5fe..c9d3d2a71c9ad 100644 --- a/src/core/server/saved_objects/migrations/core/index.ts +++ b/src/core/server/saved_objects/migrations/core/index.ts @@ -23,3 +23,4 @@ export { buildActiveMappings } from './build_active_mappings'; export { CallCluster } from './call_cluster'; export { LogFn, SavedObjectsMigrationLogger } from './migration_logger'; export { MigrationResult, MigrationStatus } from './migration_coordinator'; +export { createMigrationEsClient, MigrationEsClient } from './migration_es_client'; diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index f8b203bf66d6a..78601d033f8d8 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -18,18 +18,22 @@ */ import _ from 'lodash'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import { SavedObjectUnsanitizedDoc, SavedObjectsSerializer } from '../../serialization'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { IndexMigrator } from './index_migrator'; +import { MigrationOpts } from './migration_context'; import { loggingSystemMock } from '../../../logging/logging_system.mock'; describe('IndexMigrator', () => { - let testOpts: any; + let testOpts: jest.Mocked & { + client: ReturnType; + }; beforeEach(() => { testOpts = { batchSize: 10, - callCluster: jest.fn(), + client: elasticsearchClientMock.createElasticSearchClient(), index: '.kibana', log: loggingSystemMock.create().get(), mappingProperties: {}, @@ -44,15 +48,15 @@ describe('IndexMigrator', () => { }); test('creates the index if it does not exist', async () => { - const { callCluster } = testOpts; + const { client } = testOpts; - testOpts.mappingProperties = { foo: { type: 'long' } }; + testOpts.mappingProperties = { foo: { type: 'long' } as any }; - withIndex(callCluster, { index: { status: 404 }, alias: { status: 404 } }); + withIndex(client, { index: { statusCode: 404 }, alias: { statusCode: 404 } }); await new IndexMigrator(testOpts).migrate(); - expect(callCluster).toHaveBeenCalledWith('indices.create', { + expect(client.indices.create).toHaveBeenCalledWith({ body: { mappings: { dynamic: 'strict', @@ -91,9 +95,9 @@ describe('IndexMigrator', () => { }); test('returns stats about the migration', async () => { - const { callCluster } = testOpts; + const { client } = testOpts; - withIndex(callCluster, { index: { status: 404 }, alias: { status: 404 } }); + withIndex(client, { index: { statusCode: 404 }, alias: { statusCode: 404 } }); const result = await new IndexMigrator(testOpts).migrate(); @@ -105,9 +109,9 @@ describe('IndexMigrator', () => { }); test('fails if there are multiple root doc types', async () => { - const { callCluster } = testOpts; + const { client } = testOpts; - withIndex(callCluster, { + withIndex(client, { index: { '.kibana_1': { aliases: {}, @@ -129,9 +133,9 @@ describe('IndexMigrator', () => { }); test('fails if root doc type is not "doc"', async () => { - const { callCluster } = testOpts; + const { client } = testOpts; - withIndex(callCluster, { + withIndex(client, { index: { '.kibana_1': { aliases: {}, @@ -152,11 +156,11 @@ describe('IndexMigrator', () => { }); test('retains unknown core field mappings from the previous index', async () => { - const { callCluster } = testOpts; + const { client } = testOpts; - testOpts.mappingProperties = { foo: { type: 'text' } }; + testOpts.mappingProperties = { foo: { type: 'text' } as any }; - withIndex(callCluster, { + withIndex(client, { index: { '.kibana_1': { aliases: {}, @@ -171,7 +175,7 @@ describe('IndexMigrator', () => { await new IndexMigrator(testOpts).migrate(); - expect(callCluster).toHaveBeenCalledWith('indices.create', { + expect(client.indices.create).toHaveBeenCalledWith({ body: { mappings: { dynamic: 'strict', @@ -211,11 +215,11 @@ describe('IndexMigrator', () => { }); test('disables complex field mappings from unknown types in the previous index', async () => { - const { callCluster } = testOpts; + const { client } = testOpts; - testOpts.mappingProperties = { foo: { type: 'text' } }; + testOpts.mappingProperties = { foo: { type: 'text' } as any }; - withIndex(callCluster, { + withIndex(client, { index: { '.kibana_1': { aliases: {}, @@ -230,7 +234,7 @@ describe('IndexMigrator', () => { await new IndexMigrator(testOpts).migrate(); - expect(callCluster).toHaveBeenCalledWith('indices.create', { + expect(client.indices.create).toHaveBeenCalledWith({ body: { mappings: { dynamic: 'strict', @@ -270,31 +274,31 @@ describe('IndexMigrator', () => { }); test('points the alias at the dest index', async () => { - const { callCluster } = testOpts; + const { client } = testOpts; - withIndex(callCluster, { index: { status: 404 }, alias: { status: 404 } }); + withIndex(client, { index: { statusCode: 404 }, alias: { statusCode: 404 } }); await new IndexMigrator(testOpts).migrate(); - expect(callCluster).toHaveBeenCalledWith('indices.create', expect.any(Object)); - expect(callCluster).toHaveBeenCalledWith('indices.updateAliases', { + expect(client.indices.create).toHaveBeenCalledWith(expect.any(Object)); + expect(client.indices.updateAliases).toHaveBeenCalledWith({ body: { actions: [{ add: { alias: '.kibana', index: '.kibana_1' } }] }, }); }); test('removes previous indices from the alias', async () => { - const { callCluster } = testOpts; + const { client } = testOpts; testOpts.documentMigrator.migrationVersion = { dashboard: '2.4.5', }; - withIndex(callCluster, { numOutOfDate: 1 }); + withIndex(client, { numOutOfDate: 1 }); await new IndexMigrator(testOpts).migrate(); - expect(callCluster).toHaveBeenCalledWith('indices.create', expect.any(Object)); - expect(callCluster).toHaveBeenCalledWith('indices.updateAliases', { + expect(client.indices.create).toHaveBeenCalledWith(expect.any(Object)); + expect(client.indices.updateAliases).toHaveBeenCalledWith({ body: { actions: [ { remove: { alias: '.kibana', index: '.kibana_1' } }, @@ -306,7 +310,7 @@ describe('IndexMigrator', () => { test('transforms all docs from the original index', async () => { let count = 0; - const { callCluster } = testOpts; + const { client } = testOpts; const migrateDoc = jest.fn((doc: SavedObjectUnsanitizedDoc) => { return { ...doc, @@ -319,7 +323,7 @@ describe('IndexMigrator', () => { migrate: migrateDoc, }; - withIndex(callCluster, { + withIndex(client, { numOutOfDate: 1, docs: [ [{ _id: 'foo:1', _source: { type: 'foo', foo: { name: 'Bar' } } }], @@ -344,30 +348,27 @@ describe('IndexMigrator', () => { migrationVersion: {}, references: [], }); - const bulkCalls = callCluster.mock.calls.filter(([action]: any) => action === 'bulk'); - expect(bulkCalls.length).toEqual(2); - expect(bulkCalls[0]).toEqual([ - 'bulk', - { - body: [ - { index: { _id: 'foo:1', _index: '.kibana_2' } }, - { foo: { name: 1 }, type: 'foo', migrationVersion: {}, references: [] }, - ], - }, - ]); - expect(bulkCalls[1]).toEqual([ - 'bulk', - { - body: [ - { index: { _id: 'foo:2', _index: '.kibana_2' } }, - { foo: { name: 2 }, type: 'foo', migrationVersion: {}, references: [] }, - ], - }, - ]); + + expect(client.bulk).toHaveBeenCalledTimes(2); + expect(client.bulk).toHaveBeenNthCalledWith(1, { + body: [ + { index: { _id: 'foo:1', _index: '.kibana_2' } }, + { foo: { name: 1 }, type: 'foo', migrationVersion: {}, references: [] }, + ], + }); + expect(client.bulk).toHaveBeenNthCalledWith(2, { + body: [ + { index: { _id: 'foo:2', _index: '.kibana_2' } }, + { foo: { name: 2 }, type: 'foo', migrationVersion: {}, references: [] }, + ], + }); }); }); -function withIndex(callCluster: jest.Mock, opts: any = {}) { +function withIndex( + client: ReturnType, + opts: any = {} +) { const defaultIndex = { '.kibana_1': { aliases: { '.kibana': {} }, @@ -386,39 +387,56 @@ function withIndex(callCluster: jest.Mock, opts: any = {}) { const { alias = defaultAlias } = opts; const { index = defaultIndex } = opts; const { docs = [] } = opts; - const searchResult = (i: number) => - Promise.resolve({ - _scroll_id: i, - _shards: { - successful: 1, - total: 1, - }, - hits: { - hits: docs[i] || [], - }, - }); + const searchResult = (i: number) => ({ + _scroll_id: i, + _shards: { + successful: 1, + total: 1, + }, + hits: { + hits: docs[i] || [], + }, + }); let scrollCallCounter = 1; - callCluster.mockImplementation((method) => { - if (method === 'indices.get') { - return Promise.resolve(index); - } else if (method === 'indices.getAlias') { - return Promise.resolve(alias); - } else if (method === 'reindex') { - return Promise.resolve({ task: 'zeid', _shards: { successful: 1, total: 1 } }); - } else if (method === 'tasks.get') { - return Promise.resolve({ completed: true }); - } else if (method === 'search') { - return searchResult(0); - } else if (method === 'bulk') { - return Promise.resolve({ items: [] }); - } else if (method === 'count') { - return Promise.resolve({ count: numOutOfDate, _shards: { successful: 1, total: 1 } }); - } else if (method === 'scroll' && scrollCallCounter <= docs.length) { + client.indices.get.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(index, { + statusCode: index.statusCode, + }) + ); + client.indices.getAlias.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(alias, { + statusCode: index.statusCode, + }) + ); + client.reindex.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + task: 'zeid', + _shards: { successful: 1, total: 1 }, + }) + ); + client.tasks.get.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ completed: true }) + ); + client.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(searchResult(0)) + ); + client.bulk.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ items: [] }) + ); + client.count.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + count: numOutOfDate, + _shards: { successful: 1, total: 1 }, + }) + ); + client.scroll.mockImplementation(() => { + if (scrollCallCounter <= docs.length) { const result = searchResult(scrollCallCounter); scrollCallCounter++; - return result; + return elasticsearchClientMock.createSuccessTransportRequestPromise(result); } + return elasticsearchClientMock.createSuccessTransportRequestPromise({}); }); } diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.ts b/src/core/server/saved_objects/migrations/core/index_migrator.ts index e588eb7877322..ceca27fa87723 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.ts @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - import { diffMappings } from './build_active_mappings'; import * as Index from './elastic_index'; import { migrateRawDocs } from './migrate_raw_docs'; @@ -71,11 +70,11 @@ export class IndexMigrator { * Determines what action the migration system needs to take (none, patch, migrate). */ async function requiresMigration(context: Context): Promise { - const { callCluster, alias, documentMigrator, dest, log } = context; + const { client, alias, documentMigrator, dest, log } = context; // Have all of our known migrations been run against the index? const hasMigrations = await Index.migrationsUpToDate( - callCluster, + client, alias, documentMigrator.migrationVersion ); @@ -85,7 +84,7 @@ async function requiresMigration(context: Context): Promise { } // Is our index aliased? - const refreshedSource = await Index.fetchInfo(callCluster, alias); + const refreshedSource = await Index.fetchInfo(client, alias); if (!refreshedSource.aliases[alias]) { return true; @@ -109,19 +108,19 @@ async function requiresMigration(context: Context): Promise { */ async function migrateIndex(context: Context): Promise { const startTime = Date.now(); - const { callCluster, alias, source, dest, log } = context; + const { client, alias, source, dest, log } = context; await deleteIndexTemplates(context); log.info(`Creating index ${dest.indexName}.`); - await Index.createIndex(callCluster, dest.indexName, dest.mappings); + await Index.createIndex(client, dest.indexName, dest.mappings); await migrateSourceToDest(context); log.info(`Pointing alias ${alias} to ${dest.indexName}.`); - await Index.claimAlias(callCluster, dest.indexName, alias); + await Index.claimAlias(client, dest.indexName, alias); const result: MigrationResult = { status: 'migrated', @@ -139,12 +138,12 @@ async function migrateIndex(context: Context): Promise { * If the obsoleteIndexTemplatePattern option is specified, this will delete any index templates * that match it. */ -async function deleteIndexTemplates({ callCluster, log, obsoleteIndexTemplatePattern }: Context) { +async function deleteIndexTemplates({ client, log, obsoleteIndexTemplatePattern }: Context) { if (!obsoleteIndexTemplatePattern) { return; } - const templates = await callCluster('cat.templates', { + const { body: templates } = await client.cat.templates>({ format: 'json', name: obsoleteIndexTemplatePattern, }); @@ -157,7 +156,7 @@ async function deleteIndexTemplates({ callCluster, log, obsoleteIndexTemplatePat log.info(`Removing index templates: ${templateNames}`); - return Promise.all(templateNames.map((name) => callCluster('indices.deleteTemplate', { name }))); + return Promise.all(templateNames.map((name) => client.indices.deleteTemplate({ name }))); } /** @@ -166,7 +165,7 @@ async function deleteIndexTemplates({ callCluster, log, obsoleteIndexTemplatePat * a situation where the alias moves out from under us as we're migrating docs. */ async function migrateSourceToDest(context: Context) { - const { callCluster, alias, dest, source, batchSize } = context; + const { client, alias, dest, source, batchSize } = context; const { scrollDuration, documentMigrator, log, serializer } = context; if (!source.exists) { @@ -176,10 +175,10 @@ async function migrateSourceToDest(context: Context) { if (!source.aliases[alias]) { log.info(`Reindexing ${alias} to ${source.indexName}`); - await Index.convertToAlias(callCluster, source, alias, batchSize, context.convertToAliasScript); + await Index.convertToAlias(client, source, alias, batchSize, context.convertToAliasScript); } - const read = Index.reader(callCluster, source.indexName, { batchSize, scrollDuration }); + const read = Index.reader(client, source.indexName, { batchSize, scrollDuration }); log.info(`Migrating ${source.indexName} saved objects to ${dest.indexName}`); @@ -193,7 +192,7 @@ async function migrateSourceToDest(context: Context) { log.debug(`Migrating saved objects ${docs.map((d) => d._id).join(', ')}`); await Index.write( - callCluster, + client, dest.indexName, await migrateRawDocs(serializer, documentMigrator.migrate, docs, log) ); diff --git a/src/core/server/saved_objects/migrations/core/migration_context.ts b/src/core/server/saved_objects/migrations/core/migration_context.ts index adf1851a1aa75..0ea362d65623e 100644 --- a/src/core/server/saved_objects/migrations/core/migration_context.ts +++ b/src/core/server/saved_objects/migrations/core/migration_context.ts @@ -25,6 +25,7 @@ */ import { Logger } from 'src/core/server/logging'; +import { MigrationEsClient } from './migration_es_client'; import { SavedObjectsSerializer } from '../../serialization'; import { SavedObjectsTypeMappingDefinitions, @@ -32,16 +33,15 @@ import { IndexMapping, } from '../../mappings'; import { buildActiveMappings } from './build_active_mappings'; -import { CallCluster } from './call_cluster'; import { VersionedTransformer } from './document_migrator'; -import { fetchInfo, FullIndexInfo } from './elastic_index'; +import * as Index from './elastic_index'; import { SavedObjectsMigrationLogger, MigrationLogger } from './migration_logger'; export interface MigrationOpts { batchSize: number; pollInterval: number; scrollDuration: string; - callCluster: CallCluster; + client: MigrationEsClient; index: string; log: Logger; mappingProperties: SavedObjectsTypeMappingDefinitions; @@ -56,11 +56,14 @@ export interface MigrationOpts { obsoleteIndexTemplatePattern?: string; } +/** + * @internal + */ export interface Context { - callCluster: CallCluster; + client: MigrationEsClient; alias: string; - source: FullIndexInfo; - dest: FullIndexInfo; + source: Index.FullIndexInfo; + dest: Index.FullIndexInfo; documentMigrator: VersionedTransformer; log: SavedObjectsMigrationLogger; batchSize: number; @@ -76,13 +79,13 @@ export interface Context { * and various info needed to migrate the source index. */ export async function migrationContext(opts: MigrationOpts): Promise { - const { log, callCluster } = opts; + const { log, client } = opts; const alias = opts.index; - const source = createSourceContext(await fetchInfo(callCluster, alias), alias); + const source = createSourceContext(await Index.fetchInfo(client, alias), alias); const dest = createDestContext(source, alias, opts.mappingProperties); return { - callCluster, + client, alias, source, dest, @@ -97,7 +100,7 @@ export async function migrationContext(opts: MigrationOpts): Promise { }; } -function createSourceContext(source: FullIndexInfo, alias: string) { +function createSourceContext(source: Index.FullIndexInfo, alias: string) { if (source.exists && source.indexName === alias) { return { ...source, @@ -109,10 +112,10 @@ function createSourceContext(source: FullIndexInfo, alias: string) { } function createDestContext( - source: FullIndexInfo, + source: Index.FullIndexInfo, alias: string, typeMappingDefinitions: SavedObjectsTypeMappingDefinitions -): FullIndexInfo { +): Index.FullIndexInfo { const targetMappings = disableUnknownTypeMappingFields( buildActiveMappings(typeMappingDefinitions), source.mappings diff --git a/src/core/server/saved_objects/migrations/core/migration_es_client.test.mock.ts b/src/core/server/saved_objects/migrations/core/migration_es_client.test.mock.ts new file mode 100644 index 0000000000000..8ebed25d87cba --- /dev/null +++ b/src/core/server/saved_objects/migrations/core/migration_es_client.test.mock.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export const migrationRetryCallClusterMock = jest.fn((fn) => fn()); +jest.doMock('../../../elasticsearch/client/retry_call_cluster', () => ({ + migrationRetryCallCluster: migrationRetryCallClusterMock, +})); diff --git a/src/core/server/saved_objects/migrations/core/migration_es_client.test.ts b/src/core/server/saved_objects/migrations/core/migration_es_client.test.ts new file mode 100644 index 0000000000000..40c06677c4a5a --- /dev/null +++ b/src/core/server/saved_objects/migrations/core/migration_es_client.test.ts @@ -0,0 +1,65 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { migrationRetryCallClusterMock } from './migration_es_client.test.mock'; + +import { createMigrationEsClient, MigrationEsClient } from './migration_es_client'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { loggerMock } from '../../../logging/logger.mock'; +import { SavedObjectsErrorHelpers } from '../../service/lib/errors'; + +describe('MigrationEsClient', () => { + let client: ReturnType; + let migrationEsClient: MigrationEsClient; + + beforeEach(() => { + client = elasticsearchClientMock.createElasticSearchClient(); + migrationEsClient = createMigrationEsClient(client, loggerMock.create()); + migrationRetryCallClusterMock.mockClear(); + }); + + it('delegates call to ES client method', async () => { + expect(migrationEsClient.bulk).toStrictEqual(expect.any(Function)); + await migrationEsClient.bulk({ body: [] }); + expect(client.bulk).toHaveBeenCalledTimes(1); + }); + + it('wraps a method call in migrationRetryCallClusterMock', async () => { + await migrationEsClient.bulk({ body: [] }); + expect(migrationRetryCallClusterMock).toHaveBeenCalledTimes(1); + }); + + it('sets maxRetries: 0 to delegate retry logic to migrationRetryCallCluster', async () => { + expect(migrationEsClient.bulk).toStrictEqual(expect.any(Function)); + await migrationEsClient.bulk({ body: [] }); + expect(client.bulk).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ maxRetries: 0 }) + ); + }); + + it('do not transform elasticsearch errors into saved objects errors', async () => { + expect.assertions(1); + client.bulk = jest.fn().mockRejectedValue(new Error('reason')); + try { + await migrationEsClient.bulk({ body: [] }); + } catch (e) { + expect(SavedObjectsErrorHelpers.isSavedObjectsClientError(e)).toBe(false); + } + }); +}); diff --git a/src/core/server/saved_objects/migrations/core/migration_es_client.ts b/src/core/server/saved_objects/migrations/core/migration_es_client.ts new file mode 100644 index 0000000000000..ff859057f8fe8 --- /dev/null +++ b/src/core/server/saved_objects/migrations/core/migration_es_client.ts @@ -0,0 +1,90 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { TransportRequestOptions } from '@elastic/elasticsearch/lib/Transport'; +import { get } from 'lodash'; +import { set } from '@elastic/safer-lodash-set'; + +import { ElasticsearchClient } from '../../../elasticsearch'; +import { migrationRetryCallCluster } from '../../../elasticsearch/client/retry_call_cluster'; +import { Logger } from '../../../logging'; + +const methods = [ + 'bulk', + 'cat.templates', + 'clearScroll', + 'count', + 'indices.create', + 'indices.delete', + 'indices.deleteTemplate', + 'indices.get', + 'indices.getAlias', + 'indices.refresh', + 'indices.updateAliases', + 'reindex', + 'search', + 'scroll', + 'tasks.get', +] as const; + +type MethodName = typeof methods[number]; + +export interface MigrationEsClient { + bulk: ElasticsearchClient['bulk']; + cat: { + templates: ElasticsearchClient['cat']['templates']; + }; + clearScroll: ElasticsearchClient['clearScroll']; + count: ElasticsearchClient['count']; + indices: { + create: ElasticsearchClient['indices']['create']; + delete: ElasticsearchClient['indices']['delete']; + deleteTemplate: ElasticsearchClient['indices']['deleteTemplate']; + get: ElasticsearchClient['indices']['get']; + getAlias: ElasticsearchClient['indices']['getAlias']; + refresh: ElasticsearchClient['indices']['refresh']; + updateAliases: ElasticsearchClient['indices']['updateAliases']; + }; + reindex: ElasticsearchClient['reindex']; + search: ElasticsearchClient['search']; + scroll: ElasticsearchClient['scroll']; + tasks: { + get: ElasticsearchClient['tasks']['get']; + }; +} + +export function createMigrationEsClient( + client: ElasticsearchClient, + log: Logger, + delay?: number +): MigrationEsClient { + return methods.reduce((acc: MigrationEsClient, key: MethodName) => { + set(acc, key, async (params?: unknown, options?: TransportRequestOptions) => { + const fn = get(client, key); + if (!fn) { + throw new Error(`unknown ElasticsearchClient client method [${key}]`); + } + return await migrationRetryCallCluster( + () => fn(params, { maxRetries: 0, ...options }), + log, + delay + ); + }); + return acc; + }, {} as MigrationEsClient); +} diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts index 01b0d1cd0ba3a..c3ed97a89af80 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts @@ -18,6 +18,7 @@ */ import { take } from 'rxjs/operators'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import { KibanaMigratorOptions, KibanaMigrator } from './kibana_migrator'; import { loggingSystemMock } from '../../../logging/logging_system.mock'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; @@ -66,26 +67,44 @@ describe('KibanaMigrator', () => { describe('runMigrations', () => { it('only runs migrations once if called multiple times', async () => { const options = mockOptions(); - const clusterStub = jest.fn(() => ({ status: 404 })); - options.callCluster = clusterStub; + options.client.cat.templates.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise( + { templates: [] }, + { statusCode: 404 } + ) + ); + options.client.indices.get.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); + options.client.indices.getAlias.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); + const migrator = new KibanaMigrator(options); + await migrator.runMigrations(); await migrator.runMigrations(); - // callCluster with "cat.templates" is called by "deleteIndexTemplates" function - // and should only be done once - const callClusterCommands = clusterStub.mock.calls - .map(([callClusterPath]) => callClusterPath) - .filter((callClusterPath) => callClusterPath === 'cat.templates'); - expect(callClusterCommands.length).toBe(1); + expect(options.client.cat.templates).toHaveBeenCalledTimes(1); }); it('emits results on getMigratorResult$()', async () => { const options = mockOptions(); - const clusterStub = jest.fn(() => ({ status: 404 })); - options.callCluster = clusterStub; + options.client.cat.templates.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise( + { templates: [] }, + { statusCode: 404 } + ) + ); + options.client.indices.get.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); + options.client.indices.getAlias.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); + const migrator = new KibanaMigrator(options); const migratorStatus = migrator.getStatus$().pipe(take(3)).toPromise(); await migrator.runMigrations(); @@ -107,9 +126,12 @@ describe('KibanaMigrator', () => { }); }); -function mockOptions(): KibanaMigratorOptions { - const callCluster = jest.fn(); - return { +type MockedOptions = KibanaMigratorOptions & { + client: ReturnType; +}; + +const mockOptions = () => { + const options: MockedOptions = { logger: loggingSystemMock.create().get(), kibanaVersion: '8.2.3', savedObjectValidations: {}, @@ -148,6 +170,7 @@ function mockOptions(): KibanaMigratorOptions { scrollDuration: '10m', skip: false, }, - callCluster, + client: elasticsearchClientMock.createElasticSearchClient(), }; -} + return options; +}; diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts index 69b57a498936e..85b9099308807 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -24,25 +24,21 @@ import { KibanaConfigType } from 'src/core/server/kibana_config'; import { BehaviorSubject } from 'rxjs'; + import { Logger } from '../../../logging'; import { IndexMapping, SavedObjectsTypeMappingDefinitions } from '../../mappings'; import { SavedObjectUnsanitizedDoc, SavedObjectsSerializer } from '../../serialization'; import { docValidator, PropertyValidators } from '../../validation'; -import { - buildActiveMappings, - CallCluster, - IndexMigrator, - MigrationResult, - MigrationStatus, -} from '../core'; +import { buildActiveMappings, IndexMigrator, MigrationResult, MigrationStatus } from '../core'; import { DocumentMigrator, VersionedTransformer } from '../core/document_migrator'; +import { MigrationEsClient } from '../core/'; import { createIndexMap } from '../core/build_index_map'; import { SavedObjectsMigrationConfigType } from '../../saved_objects_config'; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectsType } from '../../types'; export interface KibanaMigratorOptions { - callCluster: CallCluster; + client: MigrationEsClient; typeRegistry: ISavedObjectTypeRegistry; savedObjectsConfig: SavedObjectsMigrationConfigType; kibanaConfig: KibanaConfigType; @@ -62,7 +58,7 @@ export interface KibanaMigratorStatus { * Manages the shape of mappings and documents in the Kibana index. */ export class KibanaMigrator { - private readonly callCluster: CallCluster; + private readonly client: MigrationEsClient; private readonly savedObjectsConfig: SavedObjectsMigrationConfigType; private readonly documentMigrator: VersionedTransformer; private readonly kibanaConfig: KibanaConfigType; @@ -80,7 +76,7 @@ export class KibanaMigrator { * Creates an instance of KibanaMigrator. */ constructor({ - callCluster, + client, typeRegistry, kibanaConfig, savedObjectsConfig, @@ -88,7 +84,7 @@ export class KibanaMigrator { kibanaVersion, logger, }: KibanaMigratorOptions) { - this.callCluster = callCluster; + this.client = client; this.kibanaConfig = kibanaConfig; this.savedObjectsConfig = savedObjectsConfig; this.typeRegistry = typeRegistry; @@ -153,7 +149,7 @@ export class KibanaMigrator { const migrators = Object.keys(indexMap).map((index) => { return new IndexMigrator({ batchSize: this.savedObjectsConfig.batchSize, - callCluster: this.callCluster, + client: this.client, documentMigrator: this.documentMigrator, index, log: this.log, diff --git a/src/core/server/saved_objects/saved_objects_service.test.ts b/src/core/server/saved_objects/saved_objects_service.test.ts index e8b2cf0b583b1..8df6a07318c45 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.ts @@ -25,18 +25,20 @@ import { } from './saved_objects_service.test.mocks'; import { BehaviorSubject } from 'rxjs'; import { ByteSizeValue } from '@kbn/config-schema'; +import { errors as esErrors } from '@elastic/elasticsearch'; + import { SavedObjectsService } from './saved_objects_service'; import { mockCoreContext } from '../core_context.mock'; -import * as legacyElasticsearch from 'elasticsearch'; import { Env } from '../config'; import { configServiceMock } from '../mocks'; import { elasticsearchServiceMock } from '../elasticsearch/elasticsearch_service.mock'; +import { elasticsearchClientMock } from '../elasticsearch/client/mocks'; import { legacyServiceMock } from '../legacy/legacy_service.mock'; import { httpServiceMock } from '../http/http_service.mock'; +import { httpServerMock } from '../http/http_server.mocks'; import { SavedObjectsClientFactoryProvider } from './service/lib'; import { NodesVersionCompatibility } from '../elasticsearch/version_check/ensure_es_version'; import { SavedObjectsRepository } from './service/lib/repository'; -import { KibanaRequest } from '../http'; jest.mock('./service/lib/repository'); @@ -70,7 +72,7 @@ describe('SavedObjectsService', () => { const createStartDeps = (pluginsInitialized: boolean = true) => { return { pluginsInitialized, - elasticsearch: elasticsearchServiceMock.createStart(), + elasticsearch: elasticsearchServiceMock.createInternalStart(), }; }; @@ -161,26 +163,27 @@ describe('SavedObjectsService', () => { }); describe('#start()', () => { - it('creates a KibanaMigrator which retries NoConnections errors from callAsInternalUser', async () => { + it('creates a KibanaMigrator which retries NoLivingConnectionsError errors from ES client', async () => { const coreContext = createCoreContext(); const soService = new SavedObjectsService(coreContext); const coreSetup = createSetupDeps(); const coreStart = createStartDeps(); - let i = 0; - coreStart.elasticsearch.legacy.client.callAsInternalUser = jest + coreStart.elasticsearch.client.asInternalUser.indices.create = jest .fn() - .mockImplementation(() => - i++ <= 2 - ? Promise.reject(new legacyElasticsearch.errors.NoConnections()) - : Promise.resolve('success') + .mockImplementationOnce(() => + Promise.reject(new esErrors.NoLivingConnectionsError('reason', {} as any)) + ) + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise('success') ); await soService.setup(coreSetup); await soService.start(coreStart, 1); - return expect(KibanaMigratorMock.mock.calls[0][0].callCluster()).resolves.toMatch('success'); + const response = await KibanaMigratorMock.mock.calls[0][0].client.indices.create(); + return expect(response.body).toBe('success'); }); it('skips KibanaMigrator migrations when pluginsInitialized=false', async () => { @@ -291,22 +294,15 @@ describe('SavedObjectsService', () => { const coreStart = createStartDeps(); const { createScopedRepository } = await soService.start(coreStart); - const req = {} as KibanaRequest; + const req = httpServerMock.createKibanaRequest(); createScopedRepository(req); - expect(coreStart.elasticsearch.legacy.client.asScoped).toHaveBeenCalledWith(req); - - const [ - { - value: { callAsCurrentUser }, - }, - ] = coreStart.elasticsearch.legacy.client.asScoped.mock.results; + expect(coreStart.elasticsearch.client.asScoped).toHaveBeenCalledWith(req); const [ - [, , , callCluster, includedHiddenTypes], + [, , , , includedHiddenTypes], ] = (SavedObjectsRepository.createRepository as jest.Mocked).mock.calls; - expect(callCluster).toBe(callAsCurrentUser); expect(includedHiddenTypes).toEqual([]); }); @@ -318,7 +314,7 @@ describe('SavedObjectsService', () => { const coreStart = createStartDeps(); const { createScopedRepository } = await soService.start(coreStart); - const req = {} as KibanaRequest; + const req = httpServerMock.createKibanaRequest(); createScopedRepository(req, ['someHiddenType']); const [ @@ -341,11 +337,10 @@ describe('SavedObjectsService', () => { createInternalRepository(); const [ - [, , , callCluster, includedHiddenTypes], + [, , , client, includedHiddenTypes], ] = (SavedObjectsRepository.createRepository as jest.Mocked).mock.calls; - expect(coreStart.elasticsearch.legacy.client.callAsInternalUser).toBe(callCluster); - expect(callCluster).toBe(coreStart.elasticsearch.legacy.client.callAsInternalUser); + expect(coreStart.elasticsearch.client.asInternalUser).toBe(client); expect(includedHiddenTypes).toEqual([]); }); diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index c2d4f49d7ee2a..f05e912b12ad8 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -30,13 +30,12 @@ import { KibanaMigrator, IKibanaMigrator } from './migrations'; import { CoreContext } from '../core_context'; import { LegacyServiceDiscoverPlugins } from '../legacy'; import { - LegacyAPICaller, - ElasticsearchServiceStart, - ILegacyClusterClient, + ElasticsearchClient, + IClusterClient, InternalElasticsearchServiceSetup, + InternalElasticsearchServiceStart, } from '../elasticsearch'; import { KibanaConfigType } from '../kibana_config'; -import { migrationsRetryCallCluster } from '../elasticsearch/legacy'; import { SavedObjectsConfigType, SavedObjectsMigrationConfigType, @@ -57,7 +56,7 @@ import { SavedObjectsSerializer } from './serialization'; import { registerRoutes } from './routes'; import { ServiceStatus } from '../status'; import { calculateStatus$ } from './status'; - +import { createMigrationEsClient } from './migrations/core/'; /** * Saved Objects is Kibana's data persistence mechanism allowing plugins to * use Elasticsearch for storing and querying state. The SavedObjectsServiceSetup API exposes methods @@ -284,7 +283,7 @@ interface WrappedClientFactoryWrapper { /** @internal */ export interface SavedObjectsStartDeps { - elasticsearch: ElasticsearchServiceStart; + elasticsearch: InternalElasticsearchServiceStart; pluginsInitialized?: boolean; } @@ -383,12 +382,12 @@ export class SavedObjectsService .atPath('kibana') .pipe(first()) .toPromise(); - const client = elasticsearch.legacy.client; + const client = elasticsearch.client; const migrator = this.createMigrator( kibanaConfig, this.config.migration, - client, + elasticsearch.client, migrationsRetryDelay ); @@ -434,21 +433,24 @@ export class SavedObjectsService await migrator.runMigrations(); } - const createRepository = (callCluster: LegacyAPICaller, includedHiddenTypes: string[] = []) => { + const createRepository = ( + esClient: ElasticsearchClient, + includedHiddenTypes: string[] = [] + ) => { return SavedObjectsRepository.createRepository( migrator, this.typeRegistry, kibanaConfig.index, - callCluster, + esClient, includedHiddenTypes ); }; const repositoryFactory: SavedObjectsRepositoryFactory = { createInternalRepository: (includedHiddenTypes?: string[]) => - createRepository(client.callAsInternalUser, includedHiddenTypes), + createRepository(client.asInternalUser, includedHiddenTypes), createScopedRepository: (req: KibanaRequest, includedHiddenTypes?: string[]) => - createRepository(client.asScoped(req).callAsCurrentUser, includedHiddenTypes), + createRepository(client.asScoped(req).asCurrentUser, includedHiddenTypes), }; const clientProvider = new SavedObjectsClientProvider({ @@ -484,7 +486,7 @@ export class SavedObjectsService private createMigrator( kibanaConfig: KibanaConfigType, savedObjectsConfig: SavedObjectsMigrationConfigType, - esClient: ILegacyClusterClient, + client: IClusterClient, migrationsRetryDelay?: number ): KibanaMigrator { return new KibanaMigrator({ @@ -494,11 +496,7 @@ export class SavedObjectsService savedObjectsConfig, savedObjectValidations: this.validations, kibanaConfig, - callCluster: migrationsRetryCallCluster( - esClient.callAsInternalUser, - this.logger, - migrationsRetryDelay - ), + client: createMigrationEsClient(client.asInternalUser, this.logger, migrationsRetryDelay), }); } } diff --git a/src/core/server/saved_objects/serialization/index.ts b/src/core/server/saved_objects/serialization/index.ts index f7f4e75704341..812a0770ad988 100644 --- a/src/core/server/saved_objects/serialization/index.ts +++ b/src/core/server/saved_objects/serialization/index.ts @@ -22,5 +22,10 @@ * the raw document format as stored in ElasticSearch. */ -export { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc, SavedObjectsRawDoc } from './types'; +export { + SavedObjectUnsanitizedDoc, + SavedObjectSanitizedDoc, + SavedObjectsRawDoc, + SavedObjectsRawDocSource, +} from './types'; export { SavedObjectsSerializer } from './serializer'; 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 1fdebd87397eb..623610eebd8d7 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 @@ -17,75 +17,93 @@ * under the License. */ -import { errors as esErrors } from 'elasticsearch'; - +import { errors as esErrors } from '@elastic/elasticsearch'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import { decorateEsError } from './decorate_es_error'; import { SavedObjectsErrorHelpers } from './errors'; describe('savedObjectsClient/decorateEsError', () => { it('always returns the same error it receives', () => { - const error = new Error(); + const error = new esErrors.ResponseError(elasticsearchClientMock.createApiResponse()); expect(decorateEsError(error)).toBe(error); }); - it('makes es.ConnectionFault a SavedObjectsClient/EsUnavailable error', () => { - const error = new esErrors.ConnectionFault(); + it('makes ConnectionError a SavedObjectsClient/EsUnavailable error', () => { + const error = new esErrors.ConnectionError( + 'reason', + elasticsearchClientMock.createApiResponse() + ); expect(SavedObjectsErrorHelpers.isEsUnavailableError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isEsUnavailableError(error)).toBe(true); }); - it('makes es.ServiceUnavailable a SavedObjectsClient/EsUnavailable error', () => { - const error = new esErrors.ServiceUnavailable(); + it('makes ServiceUnavailable a SavedObjectsClient/EsUnavailable error', () => { + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ statusCode: 503 }) + ); expect(SavedObjectsErrorHelpers.isEsUnavailableError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isEsUnavailableError(error)).toBe(true); }); - it('makes es.NoConnections a SavedObjectsClient/EsUnavailable error', () => { - const error = new esErrors.NoConnections(); + it('makes NoLivingConnectionsError a SavedObjectsClient/EsUnavailable error', () => { + const error = new esErrors.NoLivingConnectionsError( + 'reason', + elasticsearchClientMock.createApiResponse() + ); expect(SavedObjectsErrorHelpers.isEsUnavailableError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isEsUnavailableError(error)).toBe(true); }); - it('makes es.RequestTimeout a SavedObjectsClient/EsUnavailable error', () => { - const error = new esErrors.RequestTimeout(); + it('makes TimeoutError a SavedObjectsClient/EsUnavailable error', () => { + const error = new esErrors.TimeoutError('reason', elasticsearchClientMock.createApiResponse()); expect(SavedObjectsErrorHelpers.isEsUnavailableError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isEsUnavailableError(error)).toBe(true); }); - it('makes es.Conflict a SavedObjectsClient/Conflict error', () => { - const error = new esErrors.Conflict(); + it('makes Conflict a SavedObjectsClient/Conflict error', () => { + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ statusCode: 409 }) + ); expect(SavedObjectsErrorHelpers.isConflictError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isConflictError(error)).toBe(true); }); - it('makes es.AuthenticationException a SavedObjectsClient/NotAuthorized error', () => { - const error = new esErrors.AuthenticationException(); + it('makes NotAuthorized a SavedObjectsClient/NotAuthorized error', () => { + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ statusCode: 401 }) + ); expect(SavedObjectsErrorHelpers.isNotAuthorizedError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isNotAuthorizedError(error)).toBe(true); }); - it('makes es.Forbidden a SavedObjectsClient/Forbidden error', () => { - const error = new esErrors.Forbidden(); + it('makes Forbidden a SavedObjectsClient/Forbidden error', () => { + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ statusCode: 403 }) + ); expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); }); - it('makes es.RequestEntityTooLarge a SavedObjectsClient/RequestEntityTooLarge error', () => { - const error = new esErrors.RequestEntityTooLarge(); + it('makes RequestEntityTooLarge a SavedObjectsClient/RequestEntityTooLarge error', () => { + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ statusCode: 413 }) + ); expect(SavedObjectsErrorHelpers.isRequestEntityTooLargeError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isRequestEntityTooLargeError(error)).toBe(true); }); - it('discards es.NotFound errors and returns a generic NotFound error', () => { - const error = new esErrors.NotFound(); + it('discards NotFound errors and returns a generic NotFound error', () => { + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ statusCode: 404 }) + ); expect(SavedObjectsErrorHelpers.isNotFoundError(error)).toBe(false); const genericError = decorateEsError(error); expect(genericError).not.toBe(error); @@ -93,8 +111,10 @@ describe('savedObjectsClient/decorateEsError', () => { expect(SavedObjectsErrorHelpers.isNotFoundError(genericError)).toBe(true); }); - it('makes es.BadRequest a SavedObjectsClient/BadRequest error', () => { - const error = new esErrors.BadRequest(); + it('makes BadRequest a SavedObjectsClient/BadRequest error', () => { + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ statusCode: 400 }) + ); expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(true); @@ -102,10 +122,16 @@ describe('savedObjectsClient/decorateEsError', () => { describe('when es.BadRequest has a reason', () => { it('makes a SavedObjectsClient/esCannotExecuteScriptError error when script context is disabled', () => { - const error = new esErrors.BadRequest(); - (error as Record).body = { - error: { reason: 'cannot execute scripts using [update] context' }, - }; + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 400, + body: { + error: { + reason: 'cannot execute scripts using [update] context', + }, + }, + }) + ); expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(true); @@ -113,10 +139,16 @@ describe('savedObjectsClient/decorateEsError', () => { }); it('makes a SavedObjectsClient/esCannotExecuteScriptError error when inline scripts are disabled', () => { - const error = new esErrors.BadRequest(); - (error as Record).body = { - error: { reason: 'cannot execute [inline] scripts' }, - }; + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 400, + body: { + error: { + reason: 'cannot execute [inline] scripts', + }, + }, + }) + ); expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(true); @@ -124,8 +156,9 @@ describe('savedObjectsClient/decorateEsError', () => { }); it('makes a SavedObjectsClient/BadRequest error for any other reason', () => { - const error = new esErrors.BadRequest(); - (error as Record).body = { error: { reason: 'some other reason' } }; + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ statusCode: 400 }) + ); expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(true); @@ -133,7 +166,7 @@ describe('savedObjectsClient/decorateEsError', () => { }); it('returns other errors as Boom errors', () => { - const error = new Error(); + const error = new esErrors.ResponseError(elasticsearchClientMock.createApiResponse()); expect(error).not.toHaveProperty('isBoom'); expect(decorateEsError(error)).toBe(error); expect(error).toHaveProperty('isBoom'); 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 7d1575798c357..cf8a16cdaae6f 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 @@ -17,65 +17,66 @@ * under the License. */ -import * as legacyElasticsearch from 'elasticsearch'; +import { errors as esErrors } from '@elastic/elasticsearch'; import { get } from 'lodash'; -const { - ConnectionFault, - ServiceUnavailable, - NoConnections, - RequestTimeout, - Conflict, - // @ts-expect-error - 401: NotAuthorized, - // @ts-expect-error - 403: Forbidden, - // @ts-expect-error - 413: RequestEntityTooLarge, - NotFound, - BadRequest, -} = legacyElasticsearch.errors; +const responseErrors = { + isServiceUnavailable: (statusCode: number) => statusCode === 503, + isConflict: (statusCode: number) => statusCode === 409, + isNotAuthorized: (statusCode: number) => statusCode === 401, + isForbidden: (statusCode: number) => statusCode === 403, + isRequestEntityTooLarge: (statusCode: number) => statusCode === 413, + isNotFound: (statusCode: number) => statusCode === 404, + isBadRequest: (statusCode: number) => statusCode === 400, +}; +const { ConnectionError, NoLivingConnectionsError, TimeoutError } = esErrors; const SCRIPT_CONTEXT_DISABLED_REGEX = /(?:cannot execute scripts using \[)([a-z]*)(?:\] context)/; const INLINE_SCRIPTS_DISABLED_MESSAGE = 'cannot execute [inline] scripts'; import { SavedObjectsErrorHelpers } from './errors'; -export function decorateEsError(error: Error) { +type EsErrors = + | esErrors.ConnectionError + | esErrors.NoLivingConnectionsError + | esErrors.TimeoutError + | esErrors.ResponseError; + +export function decorateEsError(error: EsErrors) { if (!(error instanceof Error)) { throw new Error('Expected an instance of Error'); } const { reason } = get(error, 'body.error', { reason: undefined }) as { reason?: string }; if ( - error instanceof ConnectionFault || - error instanceof ServiceUnavailable || - error instanceof NoConnections || - error instanceof RequestTimeout + error instanceof ConnectionError || + error instanceof NoLivingConnectionsError || + error instanceof TimeoutError || + responseErrors.isServiceUnavailable(error.statusCode) ) { return SavedObjectsErrorHelpers.decorateEsUnavailableError(error, reason); } - if (error instanceof Conflict) { + if (responseErrors.isConflict(error.statusCode)) { return SavedObjectsErrorHelpers.decorateConflictError(error, reason); } - if (error instanceof NotAuthorized) { + if (responseErrors.isNotAuthorized(error.statusCode)) { return SavedObjectsErrorHelpers.decorateNotAuthorizedError(error, reason); } - if (error instanceof Forbidden) { + if (responseErrors.isForbidden(error.statusCode)) { return SavedObjectsErrorHelpers.decorateForbiddenError(error, reason); } - if (error instanceof RequestEntityTooLarge) { + if (responseErrors.isRequestEntityTooLarge(error.statusCode)) { return SavedObjectsErrorHelpers.decorateRequestEntityTooLargeError(error, reason); } - if (error instanceof NotFound) { + if (responseErrors.isNotFound(error.statusCode)) { return SavedObjectsErrorHelpers.createGenericNotFoundError(); } - if (error instanceof BadRequest) { + if (responseErrors.isBadRequest(error.statusCode)) { if ( SCRIPT_CONTEXT_DISABLED_REGEX.test(reason || '') || reason === INLINE_SCRIPTS_DISABLED_MESSAGE 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 d563edbe66c9b..b902179b012ff 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -24,6 +24,7 @@ import { SavedObjectsSerializer } from '../../serialization'; import { encodeHitVersion } from '../../version'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { DocumentMigrator } from '../../migrations/core/document_migrator'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; jest.mock('./search_dsl/search_dsl', () => ({ getSearchDsl: jest.fn() })); @@ -40,7 +41,7 @@ const createUnsupportedTypeError = (...args) => SavedObjectsErrorHelpers.createUnsupportedTypeError(...args).output.payload; describe('SavedObjectsRepository', () => { - let callAdminCluster; + let client; let savedObjectsRepository; let migrator; @@ -170,26 +171,11 @@ describe('SavedObjectsRepository', () => { }); const getMockMgetResponse = (objects, namespace) => ({ - status: 200, docs: objects.map((obj) => obj.found === false ? obj : getMockGetResponse({ ...obj, namespace }) ), }); - const expectClusterCalls = (...actions) => { - for (let i = 0; i < actions.length; i++) { - expect(callAdminCluster).toHaveBeenNthCalledWith(i + 1, actions[i], expect.any(Object)); - } - expect(callAdminCluster).toHaveBeenCalledTimes(actions.length); - }; - const expectClusterCallArgs = (args, n = 1) => { - expect(callAdminCluster).toHaveBeenNthCalledWith( - n, - expect.any(String), - expect.objectContaining(args) - ); - }; - expect.extend({ toBeDocumentWithoutError(received, type, id) { if (received.type === type && received.id === id && !received.error) { @@ -215,7 +201,7 @@ describe('SavedObjectsRepository', () => { }; beforeEach(() => { - callAdminCluster = jest.fn(); + client = elasticsearchClientMock.createElasticSearchClient(); migrator = { migrateDocument: jest.fn().mockImplementation(documentMigrator.migrate), runMigrations: async () => ({ status: 'skipped' }), @@ -240,7 +226,7 @@ describe('SavedObjectsRepository', () => { savedObjectsRepository = new SavedObjectsRepository({ index: '.kibana-test', mappings, - callCluster: callAdminCluster, + client, migrator, typeRegistry: registry, serializer, @@ -248,7 +234,7 @@ describe('SavedObjectsRepository', () => { }); savedObjectsRepository._getCurrentTime = jest.fn(() => mockTimestamp); - getSearchDslNS.getSearchDsl.mockReset(); + getSearchDslNS.getSearchDsl.mockClear(); }); const mockMigrationVersion = { foo: '2.3.4' }; @@ -274,25 +260,29 @@ describe('SavedObjectsRepository', () => { // mock a document that exists in two namespaces const mockResponse = getMockGetResponse({ type, id }); mockResponse._source.namespaces = [currentNs1, currentNs2]; - callAdminCluster.mockResolvedValueOnce(mockResponse); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) + ); }; const addToNamespacesSuccess = async (type, id, namespaces, options) => { - mockGetResponse(type, id); // this._callCluster('get', ...) - callAdminCluster.mockResolvedValue({ - _id: `${type}:${id}`, - ...mockVersionProps, - result: 'updated', - }); // this._writeToCluster('update', ...) + mockGetResponse(type, id); + client.update.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _id: `${type}:${id}`, + ...mockVersionProps, + result: 'updated', + }) + ); const result = await savedObjectsRepository.addToNamespaces(type, id, namespaces, options); - expect(callAdminCluster).toHaveBeenCalledTimes(2); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); return result; }; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use ES get action then update action`, async () => { await addToNamespacesSuccess(type, id, [newNs1, newNs2]); - expectClusterCalls('get', 'update'); }); it(`defaults to the version of the existing document`, async () => { @@ -301,25 +291,28 @@ describe('SavedObjectsRepository', () => { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, }; - expectClusterCallArgs(versionProperties, 2); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining(versionProperties), + expect.anything() + ); }); it(`accepts version`, async () => { await addToNamespacesSuccess(type, id, [newNs1, newNs2], { version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }), }); - expectClusterCallArgs({ if_seq_no: 100, if_primary_term: 200 }, 2); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ if_seq_no: 100, if_primary_term: 200 }), + expect.anything() + ); }); it(`defaults to a refresh setting of wait_for`, async () => { await addToNamespacesSuccess(type, id, [newNs1, newNs2]); - expectClusterCallArgs({ refresh: 'wait_for' }, 2); - }); - - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - await addToNamespacesSuccess(type, id, [newNs1, newNs2], { refresh }); - expectClusterCallArgs({ refresh }, 2); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ refresh: 'wait_for' }), + expect.anything() + ); }); }); @@ -337,19 +330,19 @@ describe('SavedObjectsRepository', () => { it(`throws when type is invalid`, async () => { await expectNotFoundError('unknownType', id, [newNs1, newNs2]); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }); it(`throws when type is hidden`, async () => { await expectNotFoundError(HIDDEN_TYPE, id, [newNs1, newNs2]); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }); it(`throws when type is not multi-namespace`, async () => { const test = async (type) => { const message = `${type} doesn't support multiple namespaces`; await expectBadRequestError(type, id, [newNs1, newNs2], message); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }; await test('index-pattern'); await test(NAMESPACE_AGNOSTIC_TYPE); @@ -359,48 +352,43 @@ describe('SavedObjectsRepository', () => { const test = async (namespaces) => { const message = 'namespaces must be a non-empty array of strings'; await expectBadRequestError(type, id, namespaces, message); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }; await test([]); }); it(`throws when ES is unable to find the document during get`, async () => { - callAdminCluster.mockResolvedValue({ found: false }); // this._callCluster('get', ...) + client.get.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + ); await expectNotFoundError(type, id, [newNs1, newNs2]); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the index during get`, async () => { - callAdminCluster.mockResolvedValue({ status: 404 }); // this._callCluster('get', ...) + client.get.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); await expectNotFoundError(type, id, [newNs1, newNs2]); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when the document exists, but not in this namespace`, async () => { - mockGetResponse(type, id); // this._callCluster('get', ...) + mockGetResponse(type, id); await expectNotFoundError(type, id, [newNs1, newNs2], { namespace: 'some-other-namespace', }); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the document during update`, async () => { - mockGetResponse(type, id); // this._callCluster('get', ...) - callAdminCluster.mockResolvedValue({ status: 404 }); // this._writeToCluster('update', ...) - await expectNotFoundError(type, id, [newNs1, newNs2]); - expectClusterCalls('get', 'update'); - }); - }); - - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - let callAdminClusterCount = 0; - migrator.runMigrations = jest.fn(async () => - // runMigrations should resolve before callAdminCluster is initiated - expect(callAdminCluster).toHaveBeenCalledTimes(callAdminClusterCount++) + mockGetResponse(type, id); + client.update.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) ); - await expect(addToNamespacesSuccess(type, id, [newNs1, newNs2])).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveReturnedTimes(2); + await expectNotFoundError(type, id, [newNs1, newNs2]); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); }); }); @@ -457,17 +445,21 @@ describe('SavedObjectsRepository', () => { objects.filter(({ type, id }) => registry.isMultiNamespace(type) && id); if (multiNamespaceObjects?.length) { const response = getMockMgetResponse(multiNamespaceObjects, options?.namespace); - callAdminCluster.mockResolvedValueOnce(response); // this._callCluster('mget', ...) + client.mget.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); } const response = getMockBulkCreateResponse(objects, options?.namespace); - callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const result = await savedObjectsRepository.bulkCreate(objects, options); - expect(callAdminCluster).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 2 : 1); + expect(client.mget).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 1 : 0); return result; }; // bulk create calls have two objects for each source -- the action, and the source - const expectClusterCallArgsAction = ( + const expectClientCallArgsAction = ( objects, { method, _index = expect.any(String), getId = () => expect.any(String) } ) => { @@ -476,7 +468,10 @@ describe('SavedObjectsRepository', () => { body.push({ [method]: { _index, _id: getId(type, id) } }); body.push(expect.any(Object)); } - expectClusterCallArgs({ body }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); }; const expectObjArgs = ({ type, attributes, references }, overrides) => [ @@ -498,53 +493,60 @@ describe('SavedObjectsRepository', () => { ...mockTimestampFields, }); - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES bulk action by default`, async () => { await bulkCreateSuccess([obj1, obj2]); - expectClusterCalls('bulk'); + expect(client.bulk).toHaveBeenCalledTimes(1); }); it(`should use the ES mget action before bulk action for any types that are multi-namespace, when overwrite=true`, async () => { const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; await bulkCreateSuccess(objects, { overwrite: true }); - expectClusterCalls('mget', 'bulk'); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(client.mget).toHaveBeenCalledTimes(1); const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; - expectClusterCallArgs({ body: { docs } }, 1); + expect(client.mget.mock.calls[0][0].body).toEqual({ docs }); }); it(`should use the ES create method if ID is undefined and overwrite=true`, async () => { const objects = [obj1, obj2].map((obj) => ({ ...obj, id: undefined })); await bulkCreateSuccess(objects, { overwrite: true }); - expectClusterCallArgsAction(objects, { method: 'create' }); + expectClientCallArgsAction(objects, { method: 'create' }); }); it(`should use the ES create method if ID is undefined and overwrite=false`, async () => { const objects = [obj1, obj2].map((obj) => ({ ...obj, id: undefined })); await bulkCreateSuccess(objects); - expectClusterCallArgsAction(objects, { method: 'create' }); + expectClientCallArgsAction(objects, { method: 'create' }); }); it(`should use the ES index method if ID is defined and overwrite=true`, async () => { await bulkCreateSuccess([obj1, obj2], { overwrite: true }); - expectClusterCallArgsAction([obj1, obj2], { method: 'index' }); + expectClientCallArgsAction([obj1, obj2], { method: 'index' }); }); it(`should use the ES create method if ID is defined and overwrite=false`, async () => { await bulkCreateSuccess([obj1, obj2]); - expectClusterCallArgsAction([obj1, obj2], { method: 'create' }); + expectClientCallArgsAction([obj1, obj2], { method: 'create' }); }); it(`formats the ES request`, async () => { await bulkCreateSuccess([obj1, obj2]); const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; - expectClusterCallArgs({ body }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); }); it(`adds namespace to request body for any types that are single-namespace`, async () => { await bulkCreateSuccess([obj1, obj2], { namespace }); const expected = expect.objectContaining({ namespace }); const body = [expect.any(Object), expected, expect.any(Object), expected]; - expectClusterCallArgs({ body }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); }); it(`doesn't add namespace to request body for any types that are not single-namespace`, async () => { @@ -555,7 +557,10 @@ describe('SavedObjectsRepository', () => { await bulkCreateSuccess(objects, { namespace }); const expected = expect.not.objectContaining({ namespace: expect.anything() }); const body = [expect.any(Object), expected, expect.any(Object), expected]; - expectClusterCallArgs({ body }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); }); it(`adds namespaces to request body for any types that are multi-namespace`, async () => { @@ -565,8 +570,12 @@ describe('SavedObjectsRepository', () => { await bulkCreateSuccess(objects, { namespace, overwrite: true }); const expected = expect.objectContaining({ namespaces }); const body = [expect.any(Object), expected, expect.any(Object), expected]; - expectClusterCallArgs({ body }, 2); - callAdminCluster.mockReset(); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + client.mget.mockClear(); }; await test(undefined); await test(namespace); @@ -578,8 +587,11 @@ describe('SavedObjectsRepository', () => { await bulkCreateSuccess(objects, { namespace, overwrite: true }); const expected = expect.not.objectContaining({ namespaces: expect.anything() }); const body = [expect.any(Object), expected, expect.any(Object), expected]; - expectClusterCallArgs({ body }); - callAdminCluster.mockReset(); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); }; await test(undefined); await test(namespace); @@ -587,35 +599,32 @@ describe('SavedObjectsRepository', () => { it(`defaults to a refresh setting of wait_for`, async () => { await bulkCreateSuccess([obj1, obj2]); - expectClusterCallArgs({ refresh: 'wait_for' }); - }); - - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - await bulkCreateSuccess([obj1, obj2], { refresh }); - expectClusterCallArgs({ refresh }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ refresh: 'wait_for' }), + expect.anything() + ); }); it(`should use default index`, async () => { await bulkCreateSuccess([obj1, obj2]); - expectClusterCallArgsAction([obj1, obj2], { method: 'create', _index: '.kibana-test' }); + expectClientCallArgsAction([obj1, obj2], { method: 'create', _index: '.kibana-test' }); }); it(`should use custom index`, async () => { await bulkCreateSuccess([obj1, obj2].map((x) => ({ ...x, type: CUSTOM_INDEX_TYPE }))); - expectClusterCallArgsAction([obj1, obj2], { method: 'create', _index: 'custom' }); + expectClientCallArgsAction([obj1, obj2], { method: 'create', _index: 'custom' }); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { const getId = (type, id) => `${namespace}:${type}:${id}`; await bulkCreateSuccess([obj1, obj2], { namespace }); - expectClusterCallArgsAction([obj1, obj2], { method: 'create', getId }); + expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { const getId = (type, id) => `${type}:${id}`; await bulkCreateSuccess([obj1, obj2]); - expectClusterCallArgsAction([obj1, obj2], { method: 'create', getId }); + expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { @@ -625,7 +634,7 @@ describe('SavedObjectsRepository', () => { { ...obj2, type: MULTI_NAMESPACE_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); - expectClusterCallArgsAction(objects, { method: 'create', getId }); + expectClientCallArgsAction(objects, { method: 'create', getId }); }); }); @@ -645,14 +654,19 @@ describe('SavedObjectsRepository', () => { } else { response = getMockBulkCreateResponse([obj1, obj2]); } - callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const objects = [obj1, obj, obj2]; const result = await savedObjectsRepository.bulkCreate(objects); - expectClusterCalls('bulk'); + expect(client.bulk).toHaveBeenCalled(); const objCall = esError ? expectObjArgs(obj) : []; const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; - expectClusterCallArgs({ body }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); expect(result).toEqual({ saved_objects: [expectSuccess(obj1), expectedError, expectSuccess(obj2)], }); @@ -682,17 +696,29 @@ describe('SavedObjectsRepository', () => { }, ], }; - callAdminCluster.mockResolvedValueOnce(response1); // this._callCluster('mget', ...) + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response1) + ); const response2 = getMockBulkCreateResponse([obj1, obj2]); - callAdminCluster.mockResolvedValue(response2); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response2) + ); const options = { overwrite: true }; const result = await savedObjectsRepository.bulkCreate([obj1, obj, obj2], options); - expectClusterCalls('mget', 'bulk'); + expect(client.bulk).toHaveBeenCalled(); + expect(client.mget).toHaveBeenCalled(); + const body1 = { docs: [expect.objectContaining({ _id: `${obj.type}:${obj.id}` })] }; - expectClusterCallArgs({ body: body1 }, 1); + expect(client.mget).toHaveBeenCalledWith( + expect.objectContaining({ body: body1 }), + expect.anything() + ); const body2 = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; - expectClusterCallArgs({ body: body2 }, 2); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body: body2 }), + expect.anything() + ); expect(result).toEqual({ saved_objects: [expectSuccess(obj1), expectErrorConflict(obj), expectSuccess(obj2)], }); @@ -721,14 +747,6 @@ describe('SavedObjectsRepository', () => { }); describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - migrator.runMigrations = jest.fn(async () => - expect(callAdminCluster).not.toHaveBeenCalled() - ); - await expect(bulkCreateSuccess([obj1, obj2])).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); - it(`migrates the docs and serializes the migrated docs`, async () => { migrator.migrateDocument.mockImplementation(mockMigrateDocument); await bulkCreateSuccess([obj1, obj2]); @@ -793,9 +811,7 @@ describe('SavedObjectsRepository', () => { }); }); - it(`should return objects in the same order regardless of type`, async () => { - // TODO - }); + it.todo(`should return objects in the same order regardless of type`); it(`handles a mix of successful creates and errors`, async () => { const obj = { @@ -804,9 +820,11 @@ describe('SavedObjectsRepository', () => { }; const objects = [obj1, obj, obj2]; const response = getMockBulkCreateResponse([obj1, obj2]); - callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const result = await savedObjectsRepository.bulkCreate(objects); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(client.bulk).toHaveBeenCalledTimes(1); expect(result).toEqual({ saved_objects: [expectSuccessResult(obj1), expectError(obj), expectSuccessResult(obj2)], }); @@ -817,7 +835,9 @@ describe('SavedObjectsRepository', () => { // we returned raw ID's when an object without an id was created. const namespace = 'myspace'; const response = getMockBulkCreateResponse([obj1, obj2], namespace); - callAdminCluster.mockResolvedValueOnce(response); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); // Bulk create one object with id unspecified, and one with id specified const result = await savedObjectsRepository.bulkCreate([{ ...obj1, id: undefined }, obj2], { @@ -884,69 +904,78 @@ describe('SavedObjectsRepository', () => { ); const bulkGetSuccess = async (objects, options) => { const response = getMockMgetResponse(objects, options?.namespace); - callAdminCluster.mockReturnValue(response); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const result = await bulkGet(objects, options); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(client.mget).toHaveBeenCalledTimes(1); return result; }; - const _expectClusterCallArgs = ( + const _expectClientCallArgs = ( objects, { _index = expect.any(String), getId = () => expect.any(String) } ) => { - expectClusterCallArgs({ - body: { - docs: objects.map(({ type, id }) => - expect.objectContaining({ - _index, - _id: getId(type, id), - }) - ), - }, - }); + expect(client.mget).toHaveBeenCalledWith( + expect.objectContaining({ + body: { + docs: objects.map(({ type, id }) => + expect.objectContaining({ + _index, + _id: getId(type, id), + }) + ), + }, + }), + expect.anything() + ); }; - describe('cluster calls', () => { + describe('client calls', () => { it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { const getId = (type, id) => `${namespace}:${type}:${id}`; await bulkGetSuccess([obj1, obj2], { namespace }); - _expectClusterCallArgs([obj1, obj2], { getId }); + _expectClientCallArgs([obj1, obj2], { getId }); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { const getId = (type, id) => `${type}:${id}`; await bulkGetSuccess([obj1, obj2]); - _expectClusterCallArgs([obj1, obj2], { getId }); + _expectClientCallArgs([obj1, obj2], { getId }); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { const getId = (type, id) => `${type}:${id}`; let objects = [obj1, obj2].map((obj) => ({ ...obj, type: NAMESPACE_AGNOSTIC_TYPE })); await bulkGetSuccess(objects, { namespace }); - _expectClusterCallArgs(objects, { getId }); + _expectClientCallArgs(objects, { getId }); - callAdminCluster.mockReset(); + client.mget.mockClear(); objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); await bulkGetSuccess(objects, { namespace }); - _expectClusterCallArgs(objects, { getId }); + _expectClientCallArgs(objects, { getId }); }); }); describe('errors', () => { const bulkGetErrorInvalidType = async ([obj1, obj, obj2]) => { const response = getMockMgetResponse([obj1, obj2]); - callAdminCluster.mockResolvedValue(response); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const result = await bulkGet([obj1, obj, obj2]); - expectClusterCalls('mget'); + expect(client.mget).toHaveBeenCalled(); expect(result).toEqual({ saved_objects: [expectSuccess(obj1), expectErrorInvalidType(obj), expectSuccess(obj2)], }); }; const bulkGetErrorNotFound = async ([obj1, obj, obj2], options, response) => { - callAdminCluster.mockResolvedValue(response); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const result = await bulkGet([obj1, obj, obj2], options); - expectClusterCalls('mget'); + expect(client.mget).toHaveBeenCalled(); expect(result).toEqual({ saved_objects: [expectSuccess(obj1), expectErrorNotFound(obj), expectSuccess(obj2)], }); @@ -982,16 +1011,6 @@ describe('SavedObjectsRepository', () => { }); }); - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - migrator.runMigrations = jest.fn(async () => - expect(callAdminCluster).not.toHaveBeenCalled() - ); - await expect(bulkGetSuccess([obj1, obj2])).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); - }); - describe('returns', () => { const expectSuccessResult = ({ type, id }, doc) => ({ type, @@ -1007,14 +1026,16 @@ describe('SavedObjectsRepository', () => { it(`returns early for empty objects argument`, async () => { const result = await bulkGet([]); expect(result).toEqual({ saved_objects: [] }); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.mget).not.toHaveBeenCalled(); }); it(`formats the ES response`, async () => { const response = getMockMgetResponse([obj1, obj2]); - callAdminCluster.mockResolvedValue(response); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const result = await bulkGet([obj1, obj2]); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(client.mget).toHaveBeenCalledTimes(1); expect(result).toEqual({ saved_objects: [ expectSuccessResult(obj1, response.docs[0]), @@ -1025,10 +1046,12 @@ describe('SavedObjectsRepository', () => { it(`handles a mix of successful gets and errors`, async () => { const response = getMockMgetResponse([obj1, obj2]); - callAdminCluster.mockResolvedValue(response); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const obj = { type: 'unknownType', id: 'three' }; const result = await bulkGet([obj1, obj, obj2]); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(client.mget).toHaveBeenCalledTimes(1); expect(result).toEqual({ saved_objects: [ expectSuccessResult(obj1, response.docs[0]), @@ -1081,20 +1104,23 @@ describe('SavedObjectsRepository', () => { const multiNamespaceObjects = objects.filter(({ type }) => registry.isMultiNamespace(type)); if (multiNamespaceObjects?.length) { const response = getMockMgetResponse(multiNamespaceObjects, options?.namespace); - callAdminCluster.mockResolvedValueOnce(response); // this._callCluster('mget', ...) + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); } const response = getMockBulkUpdateResponse(objects, options?.namespace); - callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const result = await savedObjectsRepository.bulkUpdate(objects, options); - expect(callAdminCluster).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 2 : 1); + expect(client.mget).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 1 : 0); return result; }; // bulk create calls have two objects for each source -- the action, and the source - const expectClusterCallArgsAction = ( + const expectClientCallArgsAction = ( objects, - { method, _index = expect.any(String), getId = () => expect.any(String), overrides }, - n + { method, _index = expect.any(String), getId = () => expect.any(String), overrides } ) => { const body = []; for (const { type, id } of objects) { @@ -1107,7 +1133,10 @@ describe('SavedObjectsRepository', () => { }); body.push(expect.any(Object)); } - expectClusterCallArgs({ body }, n); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); }; const expectObjArgs = ({ type, attributes }) => [ @@ -1120,44 +1149,58 @@ describe('SavedObjectsRepository', () => { }, ]; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES bulk action by default`, async () => { await bulkUpdateSuccess([obj1, obj2]); - expectClusterCalls('bulk'); + expect(client.bulk).toHaveBeenCalled(); }); it(`should use the ES mget action before bulk action for any types that are multi-namespace`, async () => { const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; await bulkUpdateSuccess(objects); - expectClusterCalls('mget', 'bulk'); + expect(client.bulk).toHaveBeenCalled(); + expect(client.mget).toHaveBeenCalled(); + const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; - expectClusterCallArgs({ body: { docs } }, 1); + expect(client.mget).toHaveBeenCalledWith( + expect.objectContaining({ body: { docs } }), + expect.anything() + ); }); it(`formats the ES request`, async () => { await bulkUpdateSuccess([obj1, obj2]); const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; - expectClusterCallArgs({ body }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); }); it(`formats the ES request for any types that are multi-namespace`, async () => { const _obj2 = { ...obj2, type: MULTI_NAMESPACE_TYPE }; await bulkUpdateSuccess([obj1, _obj2]); const body = [...expectObjArgs(obj1), ...expectObjArgs(_obj2)]; - expectClusterCallArgs({ body }, 2); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); }); it(`doesnt call Elasticsearch if there are no valid objects to update`, async () => { const objects = [obj1, obj2].map((x) => ({ ...x, type: 'unknownType' })); await savedObjectsRepository.bulkUpdate(objects); - expect(callAdminCluster).toHaveBeenCalledTimes(0); + expect(client.bulk).toHaveBeenCalledTimes(0); }); it(`defaults to no references`, async () => { await bulkUpdateSuccess([obj1, obj2]); const expected = { doc: expect.not.objectContaining({ references: expect.anything() }) }; const body = [expect.any(Object), expected, expect.any(Object), expected]; - expectClusterCallArgs({ body }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); }); it(`accepts custom references array`, async () => { @@ -1166,8 +1209,11 @@ describe('SavedObjectsRepository', () => { await bulkUpdateSuccess(objects); const expected = { doc: expect.objectContaining({ references }) }; const body = [expect.any(Object), expected, expect.any(Object), expected]; - expectClusterCallArgs({ body }); - callAdminCluster.mockReset(); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); }; await test(references); await test(['string']); @@ -1180,8 +1226,11 @@ describe('SavedObjectsRepository', () => { await bulkUpdateSuccess(objects); const expected = { doc: expect.not.objectContaining({ references: expect.anything() }) }; const body = [expect.any(Object), expected, expect.any(Object), expected]; - expectClusterCallArgs({ body }); - callAdminCluster.mockReset(); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); }; await test('string'); await test(123); @@ -1191,13 +1240,10 @@ describe('SavedObjectsRepository', () => { it(`defaults to a refresh setting of wait_for`, async () => { await bulkUpdateSuccess([obj1, obj2]); - expectClusterCallArgs({ refresh: 'wait_for' }); - }); - - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - await bulkUpdateSuccess([obj1, obj2], { refresh }); - expectClusterCallArgs({ refresh }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ refresh: 'wait_for' }), + expect.anything() + ); }); it(`defaults to the version of the existing document for multi-namespace types`, async () => { @@ -1211,13 +1257,13 @@ describe('SavedObjectsRepository', () => { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, }; - expectClusterCallArgsAction(objects, { method: 'update', overrides }, 2); + expectClientCallArgsAction(objects, { method: 'update', overrides }); }); it(`defaults to no version for types that are not multi-namespace`, async () => { const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; await bulkUpdateSuccess(objects); - expectClusterCallArgsAction(objects, { method: 'update' }); + expectClientCallArgsAction(objects, { method: 'update' }); }); it(`accepts version`, async () => { @@ -1229,27 +1275,27 @@ describe('SavedObjectsRepository', () => { ]; await bulkUpdateSuccess(objects); const overrides = { if_seq_no: 100, if_primary_term: 200 }; - expectClusterCallArgsAction(objects, { method: 'update', overrides }, 2); + expectClientCallArgsAction(objects, { method: 'update', overrides }, 2); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { const getId = (type, id) => `${namespace}:${type}:${id}`; await bulkUpdateSuccess([obj1, obj2], { namespace }); - expectClusterCallArgsAction([obj1, obj2], { method: 'update', getId }); + expectClientCallArgsAction([obj1, obj2], { method: 'update', getId }); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { const getId = (type, id) => `${type}:${id}`; await bulkUpdateSuccess([obj1, obj2]); - expectClusterCallArgsAction([obj1, obj2], { method: 'update', getId }); + expectClientCallArgsAction([obj1, obj2], { method: 'update', getId }); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { const getId = (type, id) => `${type}:${id}`; const objects1 = [{ ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }]; await bulkUpdateSuccess(objects1, { namespace }); - expectClusterCallArgsAction(objects1, { method: 'update', getId }); - callAdminCluster.mockReset(); + expectClientCallArgsAction(objects1, { method: 'update', getId }); + client.bulk.mockClear(); const overrides = { // bulkUpdate uses a preflight `get` request for multi-namespace saved objects, and specifies that version on `update` // we aren't testing for this here, but we need to include Jest assertions so this test doesn't fail @@ -1258,7 +1304,7 @@ describe('SavedObjectsRepository', () => { }; const objects2 = [{ ...obj2, type: MULTI_NAMESPACE_TYPE }]; await bulkUpdateSuccess(objects2, { namespace }); - expectClusterCallArgsAction(objects2, { method: 'update', getId, overrides }, 2); + expectClientCallArgsAction(objects2, { method: 'update', getId, overrides }, 2); }); }); @@ -1274,27 +1320,44 @@ describe('SavedObjectsRepository', () => { if (esError) { mockResponse.items[1].update = { error: esError }; } - callAdminCluster.mockResolvedValue(mockResponse); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) + ); const result = await savedObjectsRepository.bulkUpdate(objects); - expectClusterCalls('bulk'); + expect(client.bulk).toHaveBeenCalled(); const objCall = esError ? expectObjArgs(obj) : []; const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; - expectClusterCallArgs({ body }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); expect(result).toEqual({ saved_objects: [expectSuccess(obj1), expectedError, expectSuccess(obj2)], }); }; const bulkUpdateMultiError = async ([obj1, _obj, obj2], options, mgetResponse) => { - callAdminCluster.mockResolvedValueOnce(mgetResponse); // this._callCluster('mget', ...) + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mgetResponse, { + statusCode: mgetResponse.statusCode, + }) + ); + const bulkResponse = getMockBulkUpdateResponse([obj1, obj2], namespace); - callAdminCluster.mockResolvedValue(bulkResponse); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(bulkResponse) + ); const result = await savedObjectsRepository.bulkUpdate([obj1, _obj, obj2], options); - expectClusterCalls('mget', 'bulk'); + expect(client.bulk).toHaveBeenCalled(); + expect(client.mget).toHaveBeenCalled(); const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; - expectClusterCallArgs({ body }, 2); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + expect(result).toEqual({ saved_objects: [expectSuccess(obj1), expectErrorNotFound(_obj), expectSuccess(obj2)], }); @@ -1318,7 +1381,7 @@ describe('SavedObjectsRepository', () => { it(`returns error when ES is unable to find the index (mget)`, async () => { const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE }; - const mgetResponse = { status: 404 }; + const mgetResponse = { statusCode: 404 }; await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); }); @@ -1350,16 +1413,6 @@ describe('SavedObjectsRepository', () => { }); }); - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - migrator.runMigrations = jest.fn(async () => - expect(callAdminCluster).not.toHaveBeenCalled() - ); - await expect(bulkUpdateSuccess([obj1, obj2])).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveReturnedTimes(1); - }); - }); - describe('returns', () => { const expectSuccessResult = ({ type, id, attributes, references, namespaces }) => ({ type, @@ -1393,9 +1446,12 @@ describe('SavedObjectsRepository', () => { }; const objects = [obj1, obj, obj2]; const mockResponse = getMockBulkUpdateResponse(objects); - callAdminCluster.mockResolvedValue(mockResponse); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) + ); + const result = await savedObjectsRepository.bulkUpdate(objects); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(client.bulk).toHaveBeenCalledTimes(1); expect(result).toEqual({ saved_objects: [expectSuccessResult(obj1), expectError(obj), expectSuccessResult(obj2)], }); @@ -1416,10 +1472,12 @@ describe('SavedObjectsRepository', () => { describe('#create', () => { beforeEach(() => { - callAdminCluster.mockImplementation((method, params) => ({ - _id: params.id, - ...mockVersionProps, - })); + client.create.mockImplementation((params) => + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _id: params.id, + ...mockVersionProps, + }) + ); }); const type = 'index-pattern'; @@ -1436,52 +1494,49 @@ describe('SavedObjectsRepository', () => { const createSuccess = async (type, attributes, options) => { const result = await savedObjectsRepository.create(type, attributes, options); - expect(callAdminCluster).toHaveBeenCalledTimes( - registry.isMultiNamespace(type) && options.overwrite ? 2 : 1 + expect(client.get).toHaveBeenCalledTimes( + registry.isMultiNamespace(type) && options.overwrite ? 1 : 0 ); return result; }; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES create action if ID is undefined and overwrite=true`, async () => { await createSuccess(type, attributes, { overwrite: true }); - expectClusterCalls('create'); + expect(client.create).toHaveBeenCalled(); }); it(`should use the ES create action if ID is undefined and overwrite=false`, async () => { await createSuccess(type, attributes); - expectClusterCalls('create'); + expect(client.create).toHaveBeenCalled(); }); it(`should use the ES index action if ID is defined and overwrite=true`, async () => { await createSuccess(type, attributes, { id, overwrite: true }); - expectClusterCalls('index'); + expect(client.index).toHaveBeenCalled(); }); it(`should use the ES create action if ID is defined and overwrite=false`, async () => { await createSuccess(type, attributes, { id }); - expectClusterCalls('create'); + expect(client.create).toHaveBeenCalled(); }); it(`should use the ES get action then index action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, overwrite: true }); - expectClusterCalls('get', 'index'); + expect(client.get).toHaveBeenCalled(); + expect(client.index).toHaveBeenCalled(); }); it(`defaults to empty references array`, async () => { await createSuccess(type, attributes, { id }); - expectClusterCallArgs({ - body: expect.objectContaining({ references: [] }), - }); + expect(client.create.mock.calls[0][0].body.references).toEqual([]); }); it(`accepts custom references array`, async () => { const test = async (references) => { await createSuccess(type, attributes, { id, references }); - expectClusterCallArgs({ - body: expect.objectContaining({ references }), - }); - callAdminCluster.mockReset(); + expect(client.create.mock.calls[0][0].body.references).toEqual(references); + client.create.mockClear(); }; await test(references); await test(['string']); @@ -1491,10 +1546,8 @@ describe('SavedObjectsRepository', () => { it(`doesn't accept custom references if not an array`, async () => { const test = async (references) => { await createSuccess(type, attributes, { id, references }); - expectClusterCallArgs({ - body: expect.not.objectContaining({ references: expect.anything() }), - }); - callAdminCluster.mockReset(); + expect(client.create.mock.calls[0][0].body.references).not.toBeDefined(); + client.create.mockClear(); }; await test('string'); await test(123); @@ -1504,49 +1557,75 @@ describe('SavedObjectsRepository', () => { it(`defaults to a refresh setting of wait_for`, async () => { await createSuccess(type, attributes); - expectClusterCallArgs({ refresh: 'wait_for' }); - }); - - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - await createSuccess(type, attributes, { refresh }); - expectClusterCallArgs({ refresh }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ refresh: 'wait_for' }), + expect.anything() + ); }); it(`should use default index`, async () => { await createSuccess(type, attributes, { id }); - expectClusterCallArgs({ index: '.kibana-test' }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ index: '.kibana-test' }), + expect.anything() + ); }); it(`should use custom index`, async () => { await createSuccess(CUSTOM_INDEX_TYPE, attributes, { id }); - expectClusterCallArgs({ index: 'custom' }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ index: 'custom' }), + expect.anything() + ); }); it(`self-generates an id if none is provided`, async () => { await createSuccess(type, attributes); - expectClusterCallArgs({ - id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), - }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), + }), + expect.anything() + ); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { await createSuccess(type, attributes, { id, namespace }); - expectClusterCallArgs({ id: `${namespace}:${type}:${id}` }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${namespace}:${type}:${id}`, + }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { await createSuccess(type, attributes, { id }); - expectClusterCallArgs({ id: `${type}:${id}` }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${type}:${id}`, + }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { await createSuccess(NAMESPACE_AGNOSTIC_TYPE, attributes, { id, namespace }); - expectClusterCallArgs({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }); - callAdminCluster.mockReset(); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}`, + }), + expect.anything() + ); + client.create.mockClear(); await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); - expectClusterCallArgs({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${MULTI_NAMESPACE_TYPE}:${id}`, + }), + expect.anything() + ); }); }); @@ -1555,14 +1634,14 @@ describe('SavedObjectsRepository', () => { await expect(savedObjectsRepository.create('unknownType', attributes)).rejects.toThrowError( createUnsupportedTypeError('unknownType') ); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.create).not.toHaveBeenCalled(); }); it(`throws when type is hidden`, async () => { await expect(savedObjectsRepository.create(HIDDEN_TYPE, attributes)).rejects.toThrowError( createUnsupportedTypeError(HIDDEN_TYPE) ); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.create).not.toHaveBeenCalled(); }); it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { @@ -1571,7 +1650,9 @@ describe('SavedObjectsRepository', () => { id, namespace: 'bar-namespace', }); - callAdminCluster.mockResolvedValue(response); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); await expect( savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { id, @@ -1579,16 +1660,12 @@ describe('SavedObjectsRepository', () => { namespace, }) ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalled(); }); - it(`throws when automatic index creation fails`, async () => { - // TODO - }); + it.todo(`throws when automatic index creation fails`); - it(`throws when an unexpected failure occurs`, async () => { - // TODO - }); + it.todo(`throws when an unexpected failure occurs`); }); describe('migration', () => { @@ -1596,14 +1673,6 @@ describe('SavedObjectsRepository', () => { migrator.migrateDocument.mockImplementation(mockMigrateDocument); }); - it(`waits until migrations are complete before proceeding`, async () => { - migrator.runMigrations = jest.fn(async () => - expect(callAdminCluster).not.toHaveBeenCalled() - ); - await expect(createSuccess(type, attributes, { id, namespace })).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); - it(`migrates a document and serializes the migrated doc`, async () => { const migrationVersion = mockMigrationVersion; await createSuccess(type, attributes, { id, references, migrationVersion }); @@ -1628,7 +1697,7 @@ describe('SavedObjectsRepository', () => { await createSuccess(NAMESPACE_AGNOSTIC_TYPE, attributes, { id, namespace }); expectMigrationArgs({ namespace: expect.anything() }, false, 1); - callAdminCluster.mockReset(); + client.create.mockClear(); await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id }); expectMigrationArgs({ namespace: expect.anything() }, false, 2); }); @@ -1647,7 +1716,7 @@ describe('SavedObjectsRepository', () => { await createSuccess(type, attributes, { id }); expectMigrationArgs({ namespaces: expect.anything() }, false, 1); - callAdminCluster.mockReset(); + client.create.mockClear(); await createSuccess(NAMESPACE_AGNOSTIC_TYPE, attributes, { id }); expectMigrationArgs({ namespaces: expect.anything() }, false, 2); }); @@ -1678,33 +1747,43 @@ describe('SavedObjectsRepository', () => { const deleteSuccess = async (type, id, options) => { if (registry.isMultiNamespace(type)) { const mockGetResponse = getMockGetResponse({ type, id, namespace: options?.namespace }); - callAdminCluster.mockResolvedValueOnce(mockGetResponse); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockGetResponse) + ); } - callAdminCluster.mockResolvedValue({ result: 'deleted' }); // this._writeToCluster('delete', ...) + client.delete.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ result: 'deleted' }) + ); const result = await savedObjectsRepository.delete(type, id, options); - expect(callAdminCluster).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 2 : 1); + expect(client.get).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 1 : 0); return result; }; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES delete action when not using a multi-namespace type`, async () => { await deleteSuccess(type, id); - expectClusterCalls('delete'); + expect(client.delete).toHaveBeenCalledTimes(1); }); it(`should use ES get action then delete action when using a multi-namespace type with no namespaces remaining`, async () => { await deleteSuccess(MULTI_NAMESPACE_TYPE, id); - expectClusterCalls('get', 'delete'); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.delete).toHaveBeenCalledTimes(1); }); it(`should use ES get action then update action when using a multi-namespace type with one or more namespaces remaining`, async () => { const mockResponse = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }); mockResponse._source.namespaces = ['default', 'some-other-nameespace']; - callAdminCluster - .mockResolvedValueOnce(mockResponse) // this._callCluster('get', ...) - .mockResolvedValue({ result: 'updated' }); // this._writeToCluster('update', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) + ); + client.update.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ result: 'updated' }) + ); + await savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id); - expectClusterCalls('get', 'update'); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); }); it(`includes the version of the existing document when type is multi-namespace`, async () => { @@ -1713,37 +1792,49 @@ describe('SavedObjectsRepository', () => { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, }; - expectClusterCallArgs(versionProperties, 2); + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining(versionProperties), + expect.anything() + ); }); it(`defaults to a refresh setting of wait_for`, async () => { await deleteSuccess(type, id); - expectClusterCallArgs({ refresh: 'wait_for' }); - }); - - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - await deleteSuccess(type, id, { refresh }); - expectClusterCallArgs({ refresh }); + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ refresh: 'wait_for' }), + expect.anything() + ); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { await deleteSuccess(type, id, { namespace }); - expectClusterCallArgs({ id: `${namespace}:${type}:${id}` }); + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ id: `${namespace}:${type}:${id}` }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { await deleteSuccess(type, id); - expectClusterCallArgs({ id: `${type}:${id}` }); + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ id: `${type}:${id}` }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { await deleteSuccess(NAMESPACE_AGNOSTIC_TYPE, id, { namespace }); - expectClusterCallArgs({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }); + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }), + expect.anything() + ); - callAdminCluster.mockReset(); + client.delete.mockClear(); await deleteSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); - expectClusterCallArgs({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }); + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }), + expect.anything() + ); }); }); @@ -1756,73 +1847,82 @@ describe('SavedObjectsRepository', () => { it(`throws when type is invalid`, async () => { await expectNotFoundError('unknownType', id); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.delete).not.toHaveBeenCalled(); }); it(`throws when type is hidden`, async () => { await expectNotFoundError(HIDDEN_TYPE, id); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.delete).not.toHaveBeenCalled(); }); it(`throws when ES is unable to find the document during get`, async () => { - callAdminCluster.mockResolvedValue({ found: false }); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + ); await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the index during get`, async () => { - callAdminCluster.mockResolvedValue({ status: 404 }); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when the type is multi-namespace and the document exists, but not in this namespace`, async () => { const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); - callAdminCluster.mockResolvedValue(response); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the document during update`, async () => { const mockResponse = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }); mockResponse._source.namespaces = ['default', 'some-other-nameespace']; - callAdminCluster - .mockResolvedValueOnce(mockResponse) // this._callCluster('get', ...) - .mockResolvedValue({ status: 404 }); // this._writeToCluster('update', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) + ); + client.update.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); - expectClusterCalls('get', 'update'); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the document during delete`, async () => { - callAdminCluster.mockResolvedValue({ result: 'not_found' }); // this._writeToCluster('delete', ...) + client.delete.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ result: 'not_found' }) + ); await expectNotFoundError(type, id); - expectClusterCalls('delete'); + expect(client.delete).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the index during delete`, async () => { - callAdminCluster.mockResolvedValue({ error: { type: 'index_not_found_exception' } }); // this._writeToCluster('delete', ...) + client.delete.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + error: { type: 'index_not_found_exception' }, + }) + ); await expectNotFoundError(type, id); - expectClusterCalls('delete'); + expect(client.delete).toHaveBeenCalledTimes(1); }); it(`throws when ES returns an unexpected response`, async () => { - callAdminCluster.mockResolvedValue({ result: 'something unexpected' }); // this._writeToCluster('delete', ...) + client.delete.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + result: 'something unexpected', + }) + ); await expect(savedObjectsRepository.delete(type, id)).rejects.toThrowError( 'Unexpected Elasticsearch DELETE response' ); - expectClusterCalls('delete'); - }); - }); - - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - let callAdminClusterCount = 0; - migrator.runMigrations = jest.fn(async () => - // runMigrations should resolve before callAdminCluster is initiated - expect(callAdminCluster).toHaveBeenCalledTimes(callAdminClusterCount++) - ); - await expect(deleteSuccess(type, id)).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + expect(client.delete).toHaveBeenCalledTimes(1); }); }); @@ -1853,33 +1953,27 @@ describe('SavedObjectsRepository', () => { }; const deleteByNamespaceSuccess = async (namespace, options) => { - callAdminCluster.mockResolvedValue(mockUpdateResults); + client.updateByQuery.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockUpdateResults) + ); const result = await savedObjectsRepository.deleteByNamespace(namespace, options); expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(client.updateByQuery).toHaveBeenCalledTimes(1); return result; }; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES updateByQuery action`, async () => { await deleteByNamespaceSuccess(namespace); - expectClusterCalls('updateByQuery'); - }); - - it(`defaults to a refresh setting of wait_for`, async () => { - await deleteByNamespaceSuccess(namespace); - expectClusterCallArgs({ refresh: 'wait_for' }); - }); - - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - await deleteByNamespaceSuccess(namespace, { refresh }); - expectClusterCallArgs({ refresh }); + expect(client.updateByQuery).toHaveBeenCalledTimes(1); }); it(`should use all indices for types that are not namespace-agnostic`, async () => { await deleteByNamespaceSuccess(namespace); - expectClusterCallArgs({ index: ['.kibana-test', 'custom'] }, 1); + expect(client.updateByQuery).toHaveBeenCalledWith( + expect.objectContaining({ index: ['.kibana-test', 'custom'] }), + expect.anything() + ); }); }); @@ -1889,7 +1983,7 @@ describe('SavedObjectsRepository', () => { await expect(savedObjectsRepository.deleteByNamespace(namespace)).rejects.toThrowError( `namespace is required, and must be a string` ); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.updateByQuery).not.toHaveBeenCalled(); }; await test(undefined); await test(['namespace']); @@ -1898,16 +1992,6 @@ describe('SavedObjectsRepository', () => { }); }); - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - migrator.runMigrations = jest.fn(async () => - expect(callAdminCluster).not.toHaveBeenCalled() - ); - await expect(deleteByNamespaceSuccess(namespace)).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); - }); - describe('returns', () => { it(`returns the query results on success`, async () => { const result = await deleteByNamespaceSuccess(namespace); @@ -2002,64 +2086,90 @@ describe('SavedObjectsRepository', () => { const namespace = 'foo-namespace'; const findSuccess = async (options, namespace) => { - callAdminCluster.mockResolvedValue(generateSearchResults(namespace)); + client.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + generateSearchResults(namespace) + ) + ); const result = await savedObjectsRepository.find(options); expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(client.search).toHaveBeenCalledTimes(1); return result; }; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES search action`, async () => { await findSuccess({ type }); - expectClusterCalls('search'); + expect(client.search).toHaveBeenCalledTimes(1); }); it(`merges output of getSearchDsl into es request body`, async () => { const query = { query: 1, aggregations: 2 }; getSearchDslNS.getSearchDsl.mockReturnValue(query); await findSuccess({ type }); - expectClusterCallArgs({ body: expect.objectContaining({ ...query }) }); + + expect(client.search).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ ...query }), + }), + expect.anything() + ); }); it(`accepts per_page/page`, async () => { await findSuccess({ type, perPage: 10, page: 6 }); - expectClusterCallArgs({ - size: 10, - from: 50, - }); + expect(client.search).toHaveBeenCalledWith( + expect.objectContaining({ + size: 10, + from: 50, + }), + expect.anything() + ); }); it(`accepts preference`, async () => { await findSuccess({ type, preference: 'pref' }); - expectClusterCallArgs({ preference: 'pref' }); + expect(client.search).toHaveBeenCalledWith( + expect.objectContaining({ + preference: 'pref', + }), + expect.anything() + ); }); it(`can filter by fields`, async () => { await findSuccess({ type, fields: ['title'] }); - expectClusterCallArgs({ - _source: [ - `${type}.title`, - 'namespace', - 'namespaces', - 'type', - 'references', - 'migrationVersion', - 'updated_at', - 'title', - ], - }); + expect(client.search).toHaveBeenCalledWith( + expect.objectContaining({ + _source: [ + `${type}.title`, + 'namespace', + 'namespaces', + 'type', + 'references', + 'migrationVersion', + 'updated_at', + 'title', + ], + }), + expect.anything() + ); }); it(`should set rest_total_hits_as_int to true on a request`, async () => { await findSuccess({ type }); - expectClusterCallArgs({ rest_total_hits_as_int: true }); + expect(client.search).toHaveBeenCalledWith( + expect.objectContaining({ + rest_total_hits_as_int: true, + }), + expect.anything() + ); }); - it(`should not make a cluster call when attempting to find only invalid or hidden types`, async () => { + it(`should not make a client call when attempting to find only invalid or hidden types`, async () => { const test = async (types) => { await savedObjectsRepository.find({ type: types }); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.search).not.toHaveBeenCalled(); }; await test('unknownType'); @@ -2073,21 +2183,21 @@ describe('SavedObjectsRepository', () => { await expect(savedObjectsRepository.find({})).rejects.toThrowError( 'options.type must be a string or an array of strings' ); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.search).not.toHaveBeenCalled(); }); it(`throws when searchFields is defined but not an array`, async () => { await expect( savedObjectsRepository.find({ type, searchFields: 'string' }) ).rejects.toThrowError('options.searchFields must be an array'); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.search).not.toHaveBeenCalled(); }); it(`throws when fields is defined but not an array`, async () => { await expect(savedObjectsRepository.find({ type, fields: 'string' })).rejects.toThrowError( 'options.fields must be an array' ); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.search).not.toHaveBeenCalled(); }); it(`throws when KQL filter syntax is invalid`, async () => { @@ -2113,24 +2223,16 @@ describe('SavedObjectsRepository', () => { --------------------------------^: Bad Request] `); expect(getSearchDslNS.getSearchDsl).not.toHaveBeenCalled(); - expect(callAdminCluster).not.toHaveBeenCalled(); - }); - }); - - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - migrator.runMigrations = jest.fn(async () => - expect(callAdminCluster).not.toHaveBeenCalled() - ); - await expect(findSuccess({ type })).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + expect(client.search).not.toHaveBeenCalled(); }); }); describe('returns', () => { it(`formats the ES response when there is no namespace`, async () => { const noNamespaceSearchResults = generateSearchResults(); - callAdminCluster.mockReturnValue(noNamespaceSearchResults); + client.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(noNamespaceSearchResults) + ); const count = noNamespaceSearchResults.hits.hits.length; const response = await savedObjectsRepository.find({ type }); @@ -2154,7 +2256,9 @@ describe('SavedObjectsRepository', () => { it(`formats the ES response when there is a namespace`, async () => { const namespacedSearchResults = generateSearchResults(namespace); - callAdminCluster.mockReturnValue(namespacedSearchResults); + client.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(namespacedSearchResults) + ); const count = namespacedSearchResults.hits.hits.length; const response = await savedObjectsRepository.find({ type, namespaces: [namespace] }); @@ -2298,35 +2402,57 @@ describe('SavedObjectsRepository', () => { const getSuccess = async (type, id, options) => { const response = getMockGetResponse({ type, id, namespace: options?.namespace }); - callAdminCluster.mockResolvedValue(response); + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const result = await savedObjectsRepository.get(type, id, options); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(client.get).toHaveBeenCalledTimes(1); return result; }; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES get action`, async () => { await getSuccess(type, id); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { await getSuccess(type, id, { namespace }); - expectClusterCallArgs({ id: `${namespace}:${type}:${id}` }); + expect(client.get).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${namespace}:${type}:${id}`, + }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { await getSuccess(type, id); - expectClusterCallArgs({ id: `${type}:${id}` }); + expect(client.get).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${type}:${id}`, + }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { await getSuccess(NAMESPACE_AGNOSTIC_TYPE, id, { namespace }); - expectClusterCallArgs({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }); + expect(client.get).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}`, + }), + expect.anything() + ); - callAdminCluster.mockReset(); + client.get.mockClear(); await getSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); - expectClusterCallArgs({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }); + expect(client.get).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${MULTI_NAMESPACE_TYPE}:${id}`, + }), + expect.anything() + ); }); }); @@ -2339,41 +2465,37 @@ describe('SavedObjectsRepository', () => { it(`throws when type is invalid`, async () => { await expectNotFoundError('unknownType', id); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.get).not.toHaveBeenCalled(); }); it(`throws when type is hidden`, async () => { await expectNotFoundError(HIDDEN_TYPE, id); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.get).not.toHaveBeenCalled(); }); it(`throws when ES is unable to find the document during get`, async () => { - callAdminCluster.mockResolvedValue({ found: false }); + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + ); await expectNotFoundError(type, id); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the index during get`, async () => { - callAdminCluster.mockResolvedValue({ status: 404 }); + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); await expectNotFoundError(type, id); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); - callAdminCluster.mockResolvedValue(response); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); - expectClusterCalls('get'); - }); - }); - - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - migrator.runMigrations = jest.fn(async () => - expect(callAdminCluster).not.toHaveBeenCalled() + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - await expect(getSuccess(type, id)).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + expect(client.get).toHaveBeenCalledTimes(1); }); }); @@ -2419,68 +2541,93 @@ describe('SavedObjectsRepository', () => { const isMultiNamespace = registry.isMultiNamespace(type); if (isMultiNamespace) { const response = getMockGetResponse({ type, id, namespace: options?.namespace }); - callAdminCluster.mockResolvedValueOnce(response); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); } - callAdminCluster.mockImplementation((method, params) => ({ - _id: params.id, - ...mockVersionProps, - _index: '.kibana', - get: { - found: true, - _source: { - type, - ...mockTimestampFields, - [type]: { - [field]: 8468, - defaultIndex: 'logstash-*', + client.update.mockImplementation((params) => + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _id: params.id, + ...mockVersionProps, + _index: '.kibana', + get: { + found: true, + _source: { + type, + ...mockTimestampFields, + [type]: { + [field]: 8468, + defaultIndex: 'logstash-*', + }, }, }, - }, - })); + }) + ); + const result = await savedObjectsRepository.incrementCounter(type, id, field, options); - expect(callAdminCluster).toHaveBeenCalledTimes(isMultiNamespace ? 2 : 1); + expect(client.get).toHaveBeenCalledTimes(isMultiNamespace ? 1 : 0); return result; }; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES update action if type is not multi-namespace`, async () => { await incrementCounterSuccess(type, id, field, { namespace }); - expectClusterCalls('update'); + expect(client.update).toHaveBeenCalledTimes(1); }); it(`should use the ES get action then update action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, field, { namespace }); - expectClusterCalls('get', 'update'); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); }); it(`defaults to a refresh setting of wait_for`, async () => { await incrementCounterSuccess(type, id, field, { namespace }); - expectClusterCallArgs({ refresh: 'wait_for' }); - }); - - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - await incrementCounterSuccess(type, id, field, { namespace, refresh }); - expectClusterCallArgs({ refresh }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + refresh: 'wait_for', + }), + expect.anything() + ); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { await incrementCounterSuccess(type, id, field, { namespace }); - expectClusterCallArgs({ id: `${namespace}:${type}:${id}` }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${namespace}:${type}:${id}`, + }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { await incrementCounterSuccess(type, id, field); - expectClusterCallArgs({ id: `${type}:${id}` }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${type}:${id}`, + }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { await incrementCounterSuccess(NAMESPACE_AGNOSTIC_TYPE, id, field, { namespace }); - expectClusterCallArgs({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}`, + }), + expect.anything() + ); - callAdminCluster.mockReset(); + client.update.mockClear(); await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, field, { namespace }); - expectClusterCallArgs({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${MULTI_NAMESPACE_TYPE}:${id}`, + }), + expect.anything() + ); }); }); @@ -2496,7 +2643,7 @@ describe('SavedObjectsRepository', () => { await expect( savedObjectsRepository.incrementCounter(type, id, field) ).rejects.toThrowError(`"type" argument must be a string`); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }; await test(null); @@ -2510,7 +2657,7 @@ describe('SavedObjectsRepository', () => { await expect( savedObjectsRepository.incrementCounter(type, id, field) ).rejects.toThrowError(`"counterFieldName" argument must be a string`); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }; await test(null); @@ -2521,12 +2668,12 @@ describe('SavedObjectsRepository', () => { it(`throws when type is invalid`, async () => { await expectUnsupportedTypeError('unknownType', id, field); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }); it(`throws when type is hidden`, async () => { await expectUnsupportedTypeError(HIDDEN_TYPE, id, field); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }); it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { @@ -2535,11 +2682,13 @@ describe('SavedObjectsRepository', () => { id, namespace: 'bar-namespace', }); - callAdminCluster.mockResolvedValue(response); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); await expect( savedObjectsRepository.incrementCounter(MULTI_NAMESPACE_TYPE, id, field, { namespace }) ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); }); @@ -2548,16 +2697,6 @@ describe('SavedObjectsRepository', () => { migrator.migrateDocument.mockImplementation(mockMigrateDocument); }); - it(`waits until migrations are complete before proceeding`, async () => { - migrator.runMigrations = jest.fn(async () => - expect(callAdminCluster).not.toHaveBeenCalled() - ); - await expect( - incrementCounterSuccess(type, id, field, { namespace }) - ).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); - it(`migrates a document and serializes the migrated doc`, async () => { const migrationVersion = mockMigrationVersion; await incrementCounterSuccess(type, id, field, { migrationVersion }); @@ -2572,22 +2711,24 @@ describe('SavedObjectsRepository', () => { describe('returns', () => { it(`formats the ES response`, async () => { - callAdminCluster.mockImplementation((method, params) => ({ - _id: params.id, - ...mockVersionProps, - _index: '.kibana', - get: { - found: true, - _source: { - type: 'config', - ...mockTimestampFields, - config: { - buildNum: 8468, - defaultIndex: 'logstash-*', + client.update.mockImplementation((params) => + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _id: params.id, + ...mockVersionProps, + _index: '.kibana', + get: { + found: true, + _source: { + type: 'config', + ...mockTimestampFields, + config: { + buildNum: 8468, + defaultIndex: 'logstash-*', + }, }, }, - }, - })); + }) + ); const response = await savedObjectsRepository.incrementCounter( 'config', @@ -2623,7 +2764,9 @@ describe('SavedObjectsRepository', () => { // mock a document that exists in two namespaces const mockResponse = getMockGetResponse({ type, id }); mockResponse._source.namespaces = namespaces; - callAdminCluster.mockResolvedValueOnce(mockResponse); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) + ); }; const deleteFromNamespacesSuccess = async ( @@ -2633,71 +2776,96 @@ describe('SavedObjectsRepository', () => { currentNamespaces, options ) => { - mockGetResponse(type, id, currentNamespaces); // this._callCluster('get', ...) - const isDelete = currentNamespaces.every((namespace) => namespaces.includes(namespace)); - callAdminCluster.mockResolvedValue({ - _id: `${type}:${id}`, - ...mockVersionProps, - result: isDelete ? 'deleted' : 'updated', - }); // this._writeToCluster('delete', ...) *or* this._writeToCluster('update', ...) - const result = await savedObjectsRepository.deleteFromNamespaces( - type, - id, - namespaces, - options + mockGetResponse(type, id, currentNamespaces); + client.delete.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _id: `${type}:${id}`, + ...mockVersionProps, + result: 'deleted', + }) ); - expect(callAdminCluster).toHaveBeenCalledTimes(2); - return result; + client.update.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _id: `${type}:${id}`, + ...mockVersionProps, + result: 'updated', + }) + ); + + return await savedObjectsRepository.deleteFromNamespaces(type, id, namespaces, options); }; - describe('cluster calls', () => { + describe('client calls', () => { describe('delete action', () => { const deleteFromNamespacesSuccessDelete = async (expectFn, options, _type = type) => { const test = async (namespaces) => { await deleteFromNamespacesSuccess(_type, id, namespaces, namespaces, options); expectFn(); - callAdminCluster.mockReset(); + client.delete.mockClear(); + client.get.mockClear(); }; await test([namespace1]); await test([namespace1, namespace2]); }; it(`should use ES get action then delete action if the object has no namespaces remaining`, async () => { - const expectFn = () => expectClusterCalls('get', 'delete'); + const expectFn = () => { + expect(client.delete).toHaveBeenCalledTimes(1); + expect(client.get).toHaveBeenCalledTimes(1); + }; await deleteFromNamespacesSuccessDelete(expectFn); }); it(`formats the ES requests`, async () => { const expectFn = () => { - expectClusterCallArgs({ id: `${type}:${id}` }, 1); + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${type}:${id}`, + }), + expect.anything() + ); + const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, }; - expectClusterCallArgs({ id: `${type}:${id}`, ...versionProperties }, 2); + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${type}:${id}`, + ...versionProperties, + }), + expect.anything() + ); }; await deleteFromNamespacesSuccessDelete(expectFn); }); it(`defaults to a refresh setting of wait_for`, async () => { await deleteFromNamespacesSuccessDelete(() => - expectClusterCallArgs({ refresh: 'wait_for' }, 2) + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ + refresh: 'wait_for', + }), + expect.anything() + ) ); }); - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - const expectFn = () => expectClusterCallArgs({ refresh }, 2); - await deleteFromNamespacesSuccessDelete(expectFn, { refresh }); - }); - it(`should use default index`, async () => { - const expectFn = () => expectClusterCallArgs({ index: '.kibana-test' }, 2); + const expectFn = () => + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ index: '.kibana-test' }), + expect.anything() + ); await deleteFromNamespacesSuccessDelete(expectFn); }); it(`should use custom index`, async () => { - const expectFn = () => expectClusterCallArgs({ index: 'custom' }, 2); + const expectFn = () => + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ index: 'custom' }), + expect.anything() + ); await deleteFromNamespacesSuccessDelete(expectFn, {}, MULTI_NAMESPACE_CUSTOM_INDEX_TYPE); }); }); @@ -2708,55 +2876,73 @@ describe('SavedObjectsRepository', () => { const currentNamespaces = [namespace1].concat(remaining); await deleteFromNamespacesSuccess(_type, id, [namespace1], currentNamespaces, options); expectFn(); - callAdminCluster.mockReset(); + client.get.mockClear(); + client.update.mockClear(); }; await test([namespace2]); await test([namespace2, namespace3]); }; it(`should use ES get action then update action if the object has one or more namespaces remaining`, async () => { - await deleteFromNamespacesSuccessUpdate(() => expectClusterCalls('get', 'update')); + const expectFn = () => { + expect(client.update).toHaveBeenCalledTimes(1); + expect(client.get).toHaveBeenCalledTimes(1); + }; + await deleteFromNamespacesSuccessUpdate(expectFn); }); it(`formats the ES requests`, async () => { let ctr = 0; const expectFn = () => { - expectClusterCallArgs({ id: `${type}:${id}` }, 1); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${type}:${id}`, + }), + expect.anything() + ); const namespaces = ctr++ === 0 ? [namespace2] : [namespace2, namespace3]; const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, }; - expectClusterCallArgs( - { + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ id: `${type}:${id}`, ...versionProperties, body: { doc: { ...mockTimestampFields, namespaces } }, - }, - 2 + }), + expect.anything() ); }; await deleteFromNamespacesSuccessUpdate(expectFn); }); it(`defaults to a refresh setting of wait_for`, async () => { - const expectFn = () => expectClusterCallArgs({ refresh: 'wait_for' }, 2); + const expectFn = () => + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + refresh: 'wait_for', + }), + expect.anything() + ); await deleteFromNamespacesSuccessUpdate(expectFn); }); - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - const expectFn = () => expectClusterCallArgs({ refresh }, 2); - await deleteFromNamespacesSuccessUpdate(expectFn, { refresh }); - }); - it(`should use default index`, async () => { - const expectFn = () => expectClusterCallArgs({ index: '.kibana-test' }, 2); + const expectFn = () => + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ index: '.kibana-test' }), + expect.anything() + ); await deleteFromNamespacesSuccessUpdate(expectFn); }); it(`should use custom index`, async () => { - const expectFn = () => expectClusterCallArgs({ index: 'custom' }, 2); + const expectFn = () => + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ index: 'custom' }), + expect.anything() + ); await deleteFromNamespacesSuccessUpdate(expectFn, {}, MULTI_NAMESPACE_CUSTOM_INDEX_TYPE); }); }); @@ -2776,19 +2962,22 @@ describe('SavedObjectsRepository', () => { it(`throws when type is invalid`, async () => { await expectNotFoundError('unknownType', id, [namespace1, namespace2]); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.delete).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }); it(`throws when type is hidden`, async () => { await expectNotFoundError(HIDDEN_TYPE, id, [namespace1, namespace2]); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.delete).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }); it(`throws when type is not namespace-agnostic`, async () => { const test = async (type) => { const message = `${type} doesn't support multiple namespaces`; await expectBadRequestError(type, id, [namespace1, namespace2], message); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.delete).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }; await test('index-pattern'); await test(NAMESPACE_AGNOSTIC_TYPE); @@ -2798,71 +2987,78 @@ describe('SavedObjectsRepository', () => { const test = async (namespaces) => { const message = 'namespaces must be a non-empty array of strings'; await expectBadRequestError(type, id, namespaces, message); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.delete).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }; await test([]); }); it(`throws when ES is unable to find the document during get`, async () => { - callAdminCluster.mockResolvedValue({ found: false }); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + ); await expectNotFoundError(type, id, [namespace1, namespace2]); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the index during get`, async () => { - callAdminCluster.mockResolvedValue({ status: 404 }); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); await expectNotFoundError(type, id, [namespace1, namespace2]); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when the document exists, but not in this namespace`, async () => { - mockGetResponse(type, id, [namespace1]); // this._callCluster('get', ...) + mockGetResponse(type, id, [namespace1]); await expectNotFoundError(type, id, [namespace1], { namespace: 'some-other-namespace' }); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the document during delete`, async () => { - mockGetResponse(type, id, [namespace1]); // this._callCluster('get', ...) - callAdminCluster.mockResolvedValue({ result: 'not_found' }); // this._writeToCluster('delete', ...) + mockGetResponse(type, id, [namespace1]); + client.delete.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ result: 'not_found' }) + ); await expectNotFoundError(type, id, [namespace1]); - expectClusterCalls('get', 'delete'); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.delete).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the index during delete`, async () => { - mockGetResponse(type, id, [namespace1]); // this._callCluster('get', ...) - callAdminCluster.mockResolvedValue({ error: { type: 'index_not_found_exception' } }); // this._writeToCluster('delete', ...) + mockGetResponse(type, id, [namespace1]); + client.delete.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + error: { type: 'index_not_found_exception' }, + }) + ); await expectNotFoundError(type, id, [namespace1]); - expectClusterCalls('get', 'delete'); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.delete).toHaveBeenCalledTimes(1); }); it(`throws when ES returns an unexpected response`, async () => { - mockGetResponse(type, id, [namespace1]); // this._callCluster('get', ...) - callAdminCluster.mockResolvedValue({ result: 'something unexpected' }); // this._writeToCluster('delete', ...) + mockGetResponse(type, id, [namespace1]); + client.delete.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + result: 'something unexpected', + }) + ); await expect( savedObjectsRepository.deleteFromNamespaces(type, id, [namespace1]) ).rejects.toThrowError('Unexpected Elasticsearch DELETE response'); - expectClusterCalls('get', 'delete'); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.delete).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the document during update`, async () => { - mockGetResponse(type, id, [namespace1, namespace2]); // this._callCluster('get', ...) - callAdminCluster.mockResolvedValue({ status: 404 }); // this._writeToCluster('update', ...) - await expectNotFoundError(type, id, [namespace1]); - expectClusterCalls('get', 'update'); - }); - }); - - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - let callAdminClusterCount = 0; - migrator.runMigrations = jest.fn(async () => - // runMigrations should resolve before callAdminCluster is initiated - expect(callAdminCluster).toHaveBeenCalledTimes(callAdminClusterCount++) + mockGetResponse(type, id, [namespace1, namespace2]); + client.update.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) ); - await expect( - deleteFromNamespacesSuccess(type, id, [namespace1], [namespace1]) - ).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveReturnedTimes(2); + await expectNotFoundError(type, id, [namespace1]); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); }); }); @@ -2871,7 +3067,7 @@ describe('SavedObjectsRepository', () => { const test = async (namespaces) => { const result = await deleteFromNamespacesSuccess(type, id, namespaces, namespaces); expect(result).toEqual({}); - callAdminCluster.mockReset(); + client.delete.mockClear(); }; await test([namespace1]); await test([namespace1, namespace2]); @@ -2887,7 +3083,7 @@ describe('SavedObjectsRepository', () => { currentNamespaces ); expect(result).toEqual({}); - callAdminCluster.mockReset(); + client.delete.mockClear(); }; await test([namespace2]); await test([namespace2, namespace3]); @@ -2918,47 +3114,61 @@ describe('SavedObjectsRepository', () => { const updateSuccess = async (type, id, attributes, options) => { if (registry.isMultiNamespace(type)) { const mockGetResponse = getMockGetResponse({ type, id, namespace: options?.namespace }); - callAdminCluster.mockResolvedValueOnce(mockGetResponse); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockGetResponse) + ); } - callAdminCluster.mockResolvedValue({ - _id: `${type}:${id}`, - ...mockVersionProps, - result: 'updated', - // don't need the rest of the source for test purposes, just the namespace and namespaces attributes - get: { - _source: { namespaces: [options?.namespace ?? 'default'], namespace: options?.namespace }, - }, - }); // this._writeToCluster('update', ...) + client.update.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _id: `${type}:${id}`, + ...mockVersionProps, + result: 'updated', + // don't need the rest of the source for test purposes, just the namespace and namespaces attributes + get: { + _source: { + namespaces: [options?.namespace ?? 'default'], + namespace: options?.namespace, + }, + }, + }) + ); const result = await savedObjectsRepository.update(type, id, attributes, options); - expect(callAdminCluster).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 2 : 1); + expect(client.get).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 1 : 0); return result; }; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES get action then update action when type is multi-namespace`, async () => { await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); - expectClusterCalls('get', 'update'); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); }); it(`should use the ES update action when type is not multi-namespace`, async () => { await updateSuccess(type, id, attributes); - expectClusterCalls('update'); + expect(client.update).toHaveBeenCalledTimes(1); }); it(`defaults to no references array`, async () => { await updateSuccess(type, id, attributes); - expectClusterCallArgs({ - body: { doc: expect.not.objectContaining({ references: expect.anything() }) }, - }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: { doc: expect.not.objectContaining({ references: expect.anything() }) }, + }), + expect.anything() + ); }); it(`accepts custom references array`, async () => { const test = async (references) => { await updateSuccess(type, id, attributes, { references }); - expectClusterCallArgs({ - body: { doc: expect.objectContaining({ references }) }, - }); - callAdminCluster.mockReset(); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: { doc: expect.objectContaining({ references }) }, + }), + expect.anything() + ); + client.update.mockClear(); }; await test(references); await test(['string']); @@ -2968,10 +3178,13 @@ describe('SavedObjectsRepository', () => { it(`doesn't accept custom references if not an array`, async () => { const test = async (references) => { await updateSuccess(type, id, attributes, { references }); - expectClusterCallArgs({ - body: { doc: expect.not.objectContaining({ references: expect.anything() }) }, - }); - callAdminCluster.mockReset(); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: { doc: expect.not.objectContaining({ references: expect.anything() }) }, + }), + expect.anything() + ); + client.update.mockClear(); }; await test('string'); await test(123); @@ -2981,13 +3194,12 @@ describe('SavedObjectsRepository', () => { it(`defaults to a refresh setting of wait_for`, async () => { await updateSuccess(type, id, { foo: 'bar' }); - expectClusterCallArgs({ refresh: 'wait_for' }); - }); - - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - await updateSuccess(type, id, { foo: 'bar' }, { refresh }); - expectClusterCallArgs({ refresh }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + refresh: 'wait_for', + }), + expect.anything() + ); }); it(`defaults to the version of the existing document when type is multi-namespace`, async () => { @@ -2996,47 +3208,70 @@ describe('SavedObjectsRepository', () => { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, }; - expectClusterCallArgs(versionProperties, 2); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining(versionProperties), + expect.anything() + ); }); it(`accepts version`, async () => { await updateSuccess(type, id, attributes, { version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }), }); - expectClusterCallArgs({ if_seq_no: 100, if_primary_term: 200 }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ if_seq_no: 100, if_primary_term: 200 }), + expect.anything() + ); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { await updateSuccess(type, id, attributes, { namespace }); - expectClusterCallArgs({ id: expect.stringMatching(`${namespace}:${type}:${id}`) }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.stringMatching(`${namespace}:${type}:${id}`) }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { await updateSuccess(type, id, attributes, { references }); - expectClusterCallArgs({ id: expect.stringMatching(`${type}:${id}`) }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.stringMatching(`${type}:${id}`) }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { await updateSuccess(NAMESPACE_AGNOSTIC_TYPE, id, attributes, { namespace }); - expectClusterCallArgs({ id: expect.stringMatching(`${NAMESPACE_AGNOSTIC_TYPE}:${id}`) }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(`${NAMESPACE_AGNOSTIC_TYPE}:${id}`), + }), + expect.anything() + ); - callAdminCluster.mockReset(); + client.update.mockClear(); await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes, { namespace }); - expectClusterCallArgs({ id: expect.stringMatching(`${MULTI_NAMESPACE_TYPE}:${id}`) }, 2); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.stringMatching(`${MULTI_NAMESPACE_TYPE}:${id}`) }), + expect.anything() + ); }); - it(`includes _sourceIncludes when type is multi-namespace`, async () => { + it(`includes _source_includes when type is multi-namespace`, async () => { await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); - expectClusterCallArgs({ _sourceIncludes: ['namespace', 'namespaces'] }, 2); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ _source_includes: ['namespace', 'namespaces'] }), + expect.anything() + ); }); - it(`includes _sourceIncludes when type is not multi-namespace`, async () => { + it(`includes _source_includes when type is not multi-namespace`, async () => { await updateSuccess(type, id, attributes); - expect(callAdminCluster).toHaveBeenLastCalledWith( - expect.any(String), + expect(client.update).toHaveBeenLastCalledWith( expect.objectContaining({ - _sourceIncludes: ['namespace', 'namespaces'], - }) + _source_includes: ['namespace', 'namespaces'], + }), + expect.anything() ); }); }); @@ -3050,49 +3285,45 @@ describe('SavedObjectsRepository', () => { it(`throws when type is invalid`, async () => { await expectNotFoundError('unknownType', id); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }); it(`throws when type is hidden`, async () => { await expectNotFoundError(HIDDEN_TYPE, id); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }); it(`throws when ES is unable to find the document during get`, async () => { - callAdminCluster.mockResolvedValue({ found: false }); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + ); await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the index during get`, async () => { - callAdminCluster.mockResolvedValue({ status: 404 }); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); - callAdminCluster.mockResolvedValue(response); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the document during update`, async () => { - callAdminCluster.mockResolvedValue({ status: 404 }); // this._writeToCluster('update', ...) + client.update.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); await expectNotFoundError(type, id); - expectClusterCalls('update'); - }); - }); - - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - let callAdminClusterCount = 0; - migrator.runMigrations = jest.fn(async () => - // runMigrations should resolve before callAdminCluster is initiated - expect(callAdminCluster).toHaveBeenCalledTimes(callAdminClusterCount++) - ); - await expect(updateSuccess(type, id, attributes)).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveReturnedTimes(1); + 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 7a5ac9204627c..8b7b1d62c1b7d 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -19,13 +19,16 @@ import { omit } from 'lodash'; import uuid from 'uuid'; -import { retryCallCluster } from '../../../elasticsearch/legacy'; -import { LegacyAPICaller } from '../../../elasticsearch/'; - +import { + ElasticsearchClient, + DeleteDocumentResponse, + GetResponse, + SearchResponse, +} from '../../../elasticsearch/'; import { getRootPropertiesObjects, IndexMapping } from '../../mappings'; +import { createRepositoryEsClient, RepositoryEsClient } from './repository_es_client'; import { getSearchDsl } from './search_dsl'; import { includedFields } from './included_fields'; -import { decorateEsError } from './decorate_es_error'; import { SavedObjectsErrorHelpers } from './errors'; import { decodeRequestVersion, encodeVersion, encodeHitVersion } from '../../version'; import { KibanaMigrator } from '../../migrations'; @@ -33,6 +36,7 @@ import { SavedObjectsSerializer, SavedObjectSanitizedDoc, SavedObjectsRawDoc, + SavedObjectsRawDocSource, } from '../../serialization'; import { SavedObjectsBulkCreateObject, @@ -74,7 +78,7 @@ const isRight = (either: Either): either is Right => either.tag === 'Right'; export interface SavedObjectsRepositoryOptions { index: string; mappings: IndexMapping; - callCluster: LegacyAPICaller; + client: ElasticsearchClient; typeRegistry: SavedObjectTypeRegistry; serializer: SavedObjectsSerializer; migrator: KibanaMigrator; @@ -95,8 +99,8 @@ export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOpt * @public */ export interface SavedObjectsDeleteByNamespaceOptions extends SavedObjectsBaseOptions { - /** The Elasticsearch Refresh setting for this operation */ - refresh?: MutatingOperationRefreshSetting; + /** The Elasticsearch supports only boolean flag for this operation */ + refresh?: boolean; } const DEFAULT_REFRESH_SETTING = 'wait_for'; @@ -117,7 +121,7 @@ export class SavedObjectsRepository { private _mappings: IndexMapping; private _registry: SavedObjectTypeRegistry; private _allowedTypes: string[]; - private _unwrappedCallCluster: LegacyAPICaller; + private readonly client: RepositoryEsClient; private _serializer: SavedObjectsSerializer; /** @@ -132,7 +136,7 @@ export class SavedObjectsRepository { migrator: KibanaMigrator, typeRegistry: SavedObjectTypeRegistry, indexName: string, - callCluster: LegacyAPICaller, + client: ElasticsearchClient, includedHiddenTypes: string[] = [], injectedConstructor: any = SavedObjectsRepository ): ISavedObjectsRepository { @@ -157,7 +161,7 @@ export class SavedObjectsRepository { typeRegistry, serializer, allowedTypes, - callCluster: retryCallCluster(callCluster), + client, }); } @@ -165,7 +169,7 @@ export class SavedObjectsRepository { const { index, mappings, - callCluster, + client, typeRegistry, serializer, migrator, @@ -183,15 +187,11 @@ export class SavedObjectsRepository { this._index = index; this._mappings = mappings; this._registry = typeRegistry; + this.client = createRepositoryEsClient(client); if (allowedTypes.length === 0) { throw new Error('Empty or missing types for saved object repository!'); } this._allowedTypes = allowedTypes; - - this._unwrappedCallCluster = async (...args: Parameters) => { - await migrator.runMigrations(); - return callCluster(...args); - }; this._serializer = serializer; } @@ -254,17 +254,21 @@ export class SavedObjectsRepository { const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); - const method = id && overwrite ? 'index' : 'create'; - const response = await this._writeToCluster(method, { + const requestParams = { id: raw._id, index: this.getIndexForType(type), refresh, body: raw._source, - }); + }; + + const { body } = + id && overwrite + ? await this.client.index(requestParams) + : await this.client.create(requestParams); return this._rawToSavedObject({ ...raw, - ...response, + ...body, }); } @@ -322,12 +326,14 @@ export class SavedObjectsRepository { _source: ['type', 'namespaces'], })); const bulkGetResponse = bulkGetDocs.length - ? await this._callCluster('mget', { - body: { - docs: bulkGetDocs, + ? await this.client.mget( + { + body: { + docs: bulkGetDocs, + }, }, - ignore: [404], - }) + { ignore: [404] } + ) : undefined; let bulkRequestIndexCounter = 0; @@ -341,8 +347,8 @@ export class SavedObjectsRepository { let savedObjectNamespaces; const { esRequestIndex, object, method } = expectedBulkGetResult.value; if (esRequestIndex !== undefined) { - const indexFound = bulkGetResponse.status !== 404; - const actualResult = indexFound ? bulkGetResponse.docs[esRequestIndex] : undefined; + const indexFound = bulkGetResponse?.statusCode !== 404; + const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined; const docFound = indexFound && actualResult.found === true; if (docFound && !this.rawDocExistsInNamespace(actualResult, namespace)) { const { id, type } = object; @@ -395,7 +401,7 @@ export class SavedObjectsRepository { }); const bulkResponse = bulkCreateParams.length - ? await this._writeToCluster('bulk', { + ? await this.client.bulk({ refresh, body: bulkCreateParams, }) @@ -409,7 +415,7 @@ export class SavedObjectsRepository { const { requestedId, rawMigratedDoc, esRequestIndex } = expectedResult.value; const { error, ...rawResponse } = Object.values( - bulkResponse.items[esRequestIndex] + bulkResponse?.body.items[esRequestIndex] )[0] as any; if (error) { @@ -466,18 +472,20 @@ export class SavedObjectsRepository { namespaces: remainingNamespaces, }; - const updateResponse = await this._writeToCluster('update', { - id: rawId, - index: this.getIndexForType(type), - ...getExpectedVersionProperties(undefined, preflightResult), - refresh, - ignore: [404], - body: { - doc, + const { statusCode } = await this.client.update( + { + id: rawId, + index: this.getIndexForType(type), + ...getExpectedVersionProperties(undefined, preflightResult), + refresh, + body: { + doc, + }, }, - }); + { ignore: [404] } + ); - if (updateResponse.status === 404) { + if (statusCode === 404) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } @@ -485,22 +493,23 @@ export class SavedObjectsRepository { } } - const deleteResponse = await this._writeToCluster('delete', { - id: rawId, - index: this.getIndexForType(type), - ...getExpectedVersionProperties(undefined, preflightResult), - refresh, - ignore: [404], - }); + const { body, statusCode } = await this.client.delete( + { + id: rawId, + index: this.getIndexForType(type), + ...getExpectedVersionProperties(undefined, preflightResult), + refresh, + }, + { ignore: [404] } + ); - const deleted = deleteResponse.result === 'deleted'; + const deleted = body.result === 'deleted'; if (deleted) { return {}; } - const deleteDocNotFound = deleteResponse.result === 'not_found'; - const deleteIndexNotFound = - deleteResponse.error && deleteResponse.error.type === 'index_not_found_exception'; + const deleteDocNotFound = body.result === 'not_found'; + const deleteIndexNotFound = body.error && body.error.type === 'index_not_found_exception'; if (deleteDocNotFound || deleteIndexNotFound) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); @@ -510,7 +519,7 @@ export class SavedObjectsRepository { `Unexpected Elasticsearch DELETE response: ${JSON.stringify({ type, id, - response: deleteResponse, + response: { body, statusCode }, })}` ); } @@ -529,17 +538,16 @@ export class SavedObjectsRepository { throw new TypeError(`namespace is required, and must be a string`); } - const { refresh = DEFAULT_REFRESH_SETTING } = options; const allTypes = Object.keys(getRootPropertiesObjects(this._mappings)); const typesToUpdate = allTypes.filter((type) => !this._registry.isNamespaceAgnostic(type)); - const updateOptions = { - index: this.getIndicesForTypes(typesToUpdate), - ignore: [404], - refresh, - body: { - script: { - source: ` + const { body } = await this.client.updateByQuery( + { + index: this.getIndicesForTypes(typesToUpdate), + refresh: options.refresh, + body: { + script: { + source: ` if (!ctx._source.containsKey('namespaces')) { ctx.op = "delete"; } else { @@ -549,18 +557,20 @@ export class SavedObjectsRepository { } } `, - lang: 'painless', - params: { namespace: getNamespaceString(namespace) }, + lang: 'painless', + params: { namespace: getNamespaceString(namespace) }, + }, + conflicts: 'proceed', + ...getSearchDsl(this._mappings, this._registry, { + namespaces: namespace ? [namespace] : undefined, + type: typesToUpdate, + }), }, - conflicts: 'proceed', - ...getSearchDsl(this._mappings, this._registry, { - namespaces: namespace ? [namespace] : undefined, - type: typesToUpdate, - }), }, - }; + { ignore: [404] } + ); - return await this._writeToCluster('updateByQuery', updateOptions); + return body; } /** @@ -639,7 +649,6 @@ export class SavedObjectsRepository { size: perPage, from: perPage * (page - 1), _source: includedFields(type, fields), - ignore: [404], rest_total_hits_as_int: true, preference, body: { @@ -658,9 +667,10 @@ export class SavedObjectsRepository { }, }; - const response = await this._callCluster('search', esOptions); - - if (response.status === 404) { + const { body, statusCode } = await this.client.search>(esOptions, { + ignore: [404], + }); + if (statusCode === 404) { // 404 is only possible here if the index is missing, which // we don't want to leak, see "404s from missing index" above return { @@ -674,14 +684,14 @@ export class SavedObjectsRepository { return { page, per_page: perPage, - total: response.hits.total, - saved_objects: response.hits.hits.map( + total: body.hits.total, + saved_objects: body.hits.hits.map( (hit: SavedObjectsRawDoc): SavedObjectsFindResult => ({ ...this._rawToSavedObject(hit), score: (hit as any)._score, }) ), - }; + } as SavedObjectsFindResponse; } /** @@ -742,12 +752,14 @@ export class SavedObjectsRepository { _source: includedFields(type, fields), })); const bulkGetResponse = bulkGetDocs.length - ? await this._callCluster('mget', { - body: { - docs: bulkGetDocs, + ? await this.client.mget( + { + body: { + docs: bulkGetDocs, + }, }, - ignore: [404], - }) + { ignore: [404] } + ) : undefined; return { @@ -757,7 +769,7 @@ export class SavedObjectsRepository { } const { type, id, esRequestIndex } = expectedResult.value; - const doc = bulkGetResponse.docs[esRequestIndex]; + const doc = bulkGetResponse?.body.docs[esRequestIndex]; if (!doc.found || !this.rawDocExistsInNamespace(doc, namespace)) { return ({ @@ -808,24 +820,26 @@ export class SavedObjectsRepository { const { namespace } = options; - const response = await this._callCluster('get', { - id: this._serializer.generateRawId(namespace, type, id), - index: this.getIndexForType(type), - ignore: [404], - }); + const { body, statusCode } = await this.client.get>( + { + id: this._serializer.generateRawId(namespace, type, id), + index: this.getIndexForType(type), + }, + { ignore: [404] } + ); - const docNotFound = response.found === false; - const indexNotFound = response.status === 404; - if (docNotFound || indexNotFound || !this.rawDocExistsInNamespace(response, namespace)) { + const docNotFound = body.found === false; + const indexNotFound = statusCode === 404; + if (docNotFound || indexNotFound || !this.rawDocExistsInNamespace(body, namespace)) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { updated_at: updatedAt } = response._source; + const { updated_at: updatedAt } = body._source; - let namespaces = []; + let namespaces: string[] = []; if (!this._registry.isNamespaceAgnostic(type)) { - namespaces = response._source.namespaces ?? [getNamespaceString(response._source.namespace)]; + namespaces = body._source.namespaces ?? [getNamespaceString(body._source.namespace)]; } return { @@ -833,10 +847,10 @@ export class SavedObjectsRepository { type, namespaces, ...(updatedAt && { updated_at: updatedAt }), - version: encodeHitVersion(response), - attributes: response._source[type], - references: response._source.references || [], - migrationVersion: response._source.migrationVersion, + version: encodeHitVersion(body), + attributes: body._source[type], + references: body._source.references || [], + migrationVersion: body._source.migrationVersion, }; } @@ -876,35 +890,37 @@ export class SavedObjectsRepository { ...(Array.isArray(references) && { references }), }; - const updateResponse = await this._writeToCluster('update', { - id: this._serializer.generateRawId(namespace, type, id), - index: this.getIndexForType(type), - ...getExpectedVersionProperties(version, preflightResult), - refresh, - ignore: [404], - body: { - doc, + const { body, statusCode } = await this.client.update( + { + id: this._serializer.generateRawId(namespace, type, id), + index: this.getIndexForType(type), + ...getExpectedVersionProperties(version, preflightResult), + refresh, + + body: { + doc, + }, + _source_includes: ['namespace', 'namespaces'], }, - _sourceIncludes: ['namespace', 'namespaces'], - }); + { ignore: [404] } + ); - if (updateResponse.status === 404) { + if (statusCode === 404) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } let namespaces = []; if (!this._registry.isNamespaceAgnostic(type)) { - namespaces = updateResponse.get._source.namespaces ?? [ - getNamespaceString(updateResponse.get._source.namespace), - ]; + namespaces = body.get._source.namespaces ?? [getNamespaceString(body.get._source.namespace)]; } return { id, type, updated_at: time, - version: encodeHitVersion(updateResponse), + // @ts-expect-error update doesn't have _seq_no, _primary_term as Record / any in LP + version: encodeHitVersion(body), namespaces, references, attributes, @@ -952,18 +968,20 @@ export class SavedObjectsRepository { namespaces: existingNamespaces ? unique(existingNamespaces.concat(namespaces)) : namespaces, }; - const updateResponse = await this._writeToCluster('update', { - id: rawId, - index: this.getIndexForType(type), - ...getExpectedVersionProperties(version, preflightResult), - refresh, - ignore: [404], - body: { - doc, + const { statusCode } = await this.client.update( + { + id: rawId, + index: this.getIndexForType(type), + ...getExpectedVersionProperties(version, preflightResult), + refresh, + body: { + doc, + }, }, - }); + { ignore: [404] } + ); - if (updateResponse.status === 404) { + if (statusCode === 404) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } @@ -1015,40 +1033,48 @@ export class SavedObjectsRepository { namespaces: remainingNamespaces, }; - const updateResponse = await this._writeToCluster('update', { - id: rawId, - index: this.getIndexForType(type), - ...getExpectedVersionProperties(undefined, preflightResult), - refresh, - ignore: [404], - body: { - doc, + const { statusCode } = await this.client.update( + { + id: rawId, + index: this.getIndexForType(type), + ...getExpectedVersionProperties(undefined, preflightResult), + refresh, + + body: { + doc, + }, }, - }); + { + ignore: [404], + } + ); - if (updateResponse.status === 404) { + if (statusCode === 404) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } return {}; } else { // if there are no namespaces remaining, delete the saved object - const deleteResponse = await this._writeToCluster('delete', { - id: this._serializer.generateRawId(undefined, type, id), - index: this.getIndexForType(type), - ...getExpectedVersionProperties(undefined, preflightResult), - refresh, - ignore: [404], - }); + const { body, statusCode } = await this.client.delete( + { + id: this._serializer.generateRawId(undefined, type, id), + refresh, + ...getExpectedVersionProperties(undefined, preflightResult), + index: this.getIndexForType(type), + }, + { + ignore: [404], + } + ); - const deleted = deleteResponse.result === 'deleted'; + const deleted = body.result === 'deleted'; if (deleted) { return {}; } - const deleteDocNotFound = deleteResponse.result === 'not_found'; - const deleteIndexNotFound = - deleteResponse.error && deleteResponse.error.type === 'index_not_found_exception'; + const deleteDocNotFound = body.result === 'not_found'; + const deleteIndexNotFound = body.error && body.error.type === 'index_not_found_exception'; if (deleteDocNotFound || deleteIndexNotFound) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); @@ -1058,7 +1084,7 @@ export class SavedObjectsRepository { `Unexpected Elasticsearch DELETE response: ${JSON.stringify({ type, id, - response: deleteResponse, + response: { body, statusCode }, })}` ); } @@ -1125,12 +1151,16 @@ export class SavedObjectsRepository { _source: ['type', 'namespaces'], })); const bulkGetResponse = bulkGetDocs.length - ? await this._callCluster('mget', { - body: { - docs: bulkGetDocs, + ? await this.client.mget( + { + body: { + docs: bulkGetDocs, + }, }, - ignore: [404], - }) + { + ignore: [404], + } + ) : undefined; let bulkUpdateRequestIndexCounter = 0; @@ -1145,8 +1175,8 @@ export class SavedObjectsRepository { let namespaces; let versionProperties; if (esRequestIndex !== undefined) { - const indexFound = bulkGetResponse.status !== 404; - const actualResult = indexFound ? bulkGetResponse.docs[esRequestIndex] : undefined; + const indexFound = bulkGetResponse?.statusCode !== 404; + const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined; const docFound = indexFound && actualResult.found === true; if (!docFound || !this.rawDocExistsInNamespace(actualResult, namespace)) { return { @@ -1194,11 +1224,11 @@ export class SavedObjectsRepository { const { refresh = DEFAULT_REFRESH_SETTING } = options; const bulkUpdateResponse = bulkUpdateParams.length - ? await this._writeToCluster('bulk', { + ? await this.client.bulk({ refresh, body: bulkUpdateParams, }) - : {}; + : undefined; return { saved_objects: expectedBulkUpdateResults.map((expectedResult) => { @@ -1207,7 +1237,7 @@ export class SavedObjectsRepository { } const { type, id, namespaces, documentToSave, esRequestIndex } = expectedResult.value; - const response = bulkUpdateResponse.items[esRequestIndex]; + const response = bulkUpdateResponse?.body.items[esRequestIndex]; const { error, _seq_no: seqNo, _primary_term: primaryTerm } = Object.values( response )[0] as any; @@ -1283,11 +1313,11 @@ export class SavedObjectsRepository { const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); - const response = await this._writeToCluster('update', { + const { body } = await this.client.update({ id: raw._id, index: this.getIndexForType(type), refresh, - _source: true, + _source: 'true', body: { script: { source: ` @@ -1315,28 +1345,13 @@ export class SavedObjectsRepository { id, type, updated_at: time, - references: response.get._source.references, - version: encodeHitVersion(response), - attributes: response.get._source[type], + references: body.get._source.references, + // @ts-expect-error + version: encodeHitVersion(body), + attributes: body.get._source[type], }; } - private async _writeToCluster(...args: Parameters) { - try { - return await this._callCluster(...args); - } catch (err) { - throw decorateEsError(err); - } - } - - private async _callCluster(...args: Parameters) { - try { - return await this._unwrappedCallCluster(...args); - } catch (err) { - throw decorateEsError(err); - } - } - /** * Returns index specified by the given type or the default index * @@ -1408,19 +1423,23 @@ export class SavedObjectsRepository { throw new Error(`Cannot make preflight get request for non-multi-namespace type '${type}'.`); } - const response = await this._callCluster('get', { - id: this._serializer.generateRawId(undefined, type, id), - index: this.getIndexForType(type), - ignore: [404], - }); + const { body, statusCode } = await this.client.get>( + { + id: this._serializer.generateRawId(undefined, type, id), + index: this.getIndexForType(type), + }, + { + ignore: [404], + } + ); - const indexFound = response.status !== 404; - const docFound = indexFound && response.found === true; + const indexFound = statusCode !== 404; + const docFound = indexFound && body.found === true; if (docFound) { - if (!this.rawDocExistsInNamespace(response, namespace)) { + if (!this.rawDocExistsInNamespace(body, namespace)) { throw SavedObjectsErrorHelpers.createConflictError(type, id); } - return getSavedObjectNamespaces(namespace, response); + return getSavedObjectNamespaces(namespace, body); } return getSavedObjectNamespaces(namespace); } @@ -1441,18 +1460,20 @@ export class SavedObjectsRepository { } const rawId = this._serializer.generateRawId(undefined, type, id); - const response = await this._callCluster('get', { - id: rawId, - index: this.getIndexForType(type), - ignore: [404], - }); + const { body, statusCode } = await this.client.get>( + { + id: rawId, + index: this.getIndexForType(type), + }, + { ignore: [404] } + ); - const indexFound = response.status !== 404; - const docFound = indexFound && response.found === true; - if (!docFound || !this.rawDocExistsInNamespace(response, namespace)) { + const indexFound = statusCode !== 404; + const docFound = indexFound && body.found === true; + if (!docFound || !this.rawDocExistsInNamespace(body, namespace)) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - return response as SavedObjectsRawDoc; + return body as SavedObjectsRawDoc; } } diff --git a/src/core/server/saved_objects/service/lib/repository_es_client.test.mock.ts b/src/core/server/saved_objects/service/lib/repository_es_client.test.mock.ts new file mode 100644 index 0000000000000..3dcf82dae5e46 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/repository_es_client.test.mock.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export const retryCallClusterMock = jest.fn((fn) => fn()); +jest.doMock('../../../elasticsearch/client/retry_call_cluster', () => ({ + retryCallCluster: retryCallClusterMock, +})); diff --git a/src/core/server/saved_objects/service/lib/repository_es_client.test.ts b/src/core/server/saved_objects/service/lib/repository_es_client.test.ts new file mode 100644 index 0000000000000..86a984fb67124 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/repository_es_client.test.ts @@ -0,0 +1,64 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { retryCallClusterMock } from './repository_es_client.test.mock'; + +import { createRepositoryEsClient, RepositoryEsClient } from './repository_es_client'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { SavedObjectsErrorHelpers } from './errors'; + +describe('RepositoryEsClient', () => { + let client: ReturnType; + let repositoryClient: RepositoryEsClient; + + beforeEach(() => { + client = elasticsearchClientMock.createElasticSearchClient(); + repositoryClient = createRepositoryEsClient(client); + retryCallClusterMock.mockClear(); + }); + + it('delegates call to ES client method', async () => { + expect(repositoryClient.bulk).toStrictEqual(expect.any(Function)); + await repositoryClient.bulk({ body: [] }); + expect(client.bulk).toHaveBeenCalledTimes(1); + }); + + it('wraps a method call in retryCallCluster', async () => { + await repositoryClient.bulk({ body: [] }); + expect(retryCallClusterMock).toHaveBeenCalledTimes(1); + }); + + it('sets maxRetries: 0 to delegate retry logic to retryCallCluster', async () => { + expect(repositoryClient.bulk).toStrictEqual(expect.any(Function)); + await repositoryClient.bulk({ body: [] }); + expect(client.bulk).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ maxRetries: 0 }) + ); + }); + + it('transform elasticsearch errors into saved objects errors', async () => { + expect.assertions(1); + client.bulk = jest.fn().mockRejectedValue(new Error('reason')); + try { + await repositoryClient.bulk({ body: [] }); + } catch (e) { + expect(SavedObjectsErrorHelpers.isSavedObjectsClientError(e)).toBe(true); + } + }); +}); diff --git a/src/core/server/saved_objects/service/lib/repository_es_client.ts b/src/core/server/saved_objects/service/lib/repository_es_client.ts new file mode 100644 index 0000000000000..0a759669b1af8 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/repository_es_client.ts @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { TransportRequestOptions } from '@elastic/elasticsearch/lib/Transport'; + +import { ElasticsearchClient } from '../../../elasticsearch/'; +import { retryCallCluster } from '../../../elasticsearch/client/retry_call_cluster'; +import { decorateEsError } from './decorate_es_error'; + +const methods = [ + 'bulk', + 'create', + 'delete', + 'get', + 'index', + 'mget', + 'search', + 'update', + 'updateByQuery', +] as const; + +type MethodName = typeof methods[number]; + +export type RepositoryEsClient = Pick; + +export function createRepositoryEsClient(client: ElasticsearchClient): RepositoryEsClient { + return methods.reduce((acc: RepositoryEsClient, key: MethodName) => { + Object.defineProperty(acc, key, { + value: async (params?: unknown, options?: TransportRequestOptions) => { + try { + return await retryCallCluster(() => + (client[key] as Function)(params, { maxRetries: 0, ...options }) + ); + } catch (e) { + throw decorateEsError(e); + } + }, + }); + return acc; + }, {} as RepositoryEsClient); +} diff --git a/src/plugins/home/server/services/sample_data/routes/list.ts b/src/plugins/home/server/services/sample_data/routes/list.ts index 770b3116b74f1..7cce0fa5cccb3 100644 --- a/src/plugins/home/server/services/sample_data/routes/list.ts +++ b/src/plugins/home/server/services/sample_data/routes/list.ts @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import { isBoom } from 'boom'; import { IRouter } from 'src/core/server'; import { SampleDatasetSchema } from '../lib/sample_dataset_registry_types'; import { createIndexName } from '../lib/create_index_name'; @@ -75,8 +74,7 @@ export const createListRoute = (router: IRouter, sampleDatasets: SampleDatasetSc try { await context.core.savedObjects.client.get('dashboard', sampleDataset.overviewDashboard); } catch (err) { - // savedObjectClient.get() throws an boom error when object is not found. - if (isBoom(err) && err.output.statusCode === 404) { + if (context.core.savedObjects.client.errors.isNotFoundError(err)) { sampleDataset.status = NOT_INSTALLED; return; } diff --git a/test/api_integration/apis/saved_objects/migrations.js b/test/api_integration/apis/saved_objects/migrations.ts similarity index 68% rename from test/api_integration/apis/saved_objects/migrations.js rename to test/api_integration/apis/saved_objects/migrations.ts index ed259ccec0114..9997d9710e212 100644 --- a/test/api_integration/apis/saved_objects/migrations.js +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -23,22 +23,39 @@ import { set } from '@elastic/safer-lodash-set'; import _ from 'lodash'; -import { assert } from 'chai'; +import expect from '@kbn/expect'; +import { ElasticsearchClient, SavedObjectMigrationMap, SavedObjectsType } from 'src/core/server'; +import { SearchResponse } from '../../../../src/core/server/elasticsearch/client'; import { DocumentMigrator, IndexMigrator, + createMigrationEsClient, } from '../../../../src/core/server/saved_objects/migrations/core'; +import { SavedObjectsTypeMappingDefinitions } from '../../../../src/core/server/saved_objects/mappings'; + import { SavedObjectsSerializer, SavedObjectTypeRegistry, } from '../../../../src/core/server/saved_objects'; - -export default ({ getService }) => { - const es = getService('legacyEs'); - const callCluster = (path, ...args) => _.get(es, path).call(es, ...args); +import { FtrProviderContext } from '../../ftr_provider_context'; + +function getLogMock() { + return { + debug() {}, + error() {}, + fatal() {}, + info() {}, + log() {}, + trace() {}, + warn() {}, + get: getLogMock, + }; +} +export default ({ getService }: FtrProviderContext) => { + const esClient = getService('es'); describe('Kibana index migration', () => { - before(() => callCluster('indices.delete', { index: '.migrate-*' })); + before(() => esClient.indices.delete({ index: '.migrate-*' })); it('Migrates an existing index that has never been migrated before', async () => { const index = '.migration-a'; @@ -55,7 +72,7 @@ export default ({ getService }) => { bar: { properties: { mynum: { type: 'integer' } } }, }; - const migrations = { + const migrations: Record = { foo: { '1.0.0': (doc) => set(doc, 'attributes.name', doc.attributes.name.toUpperCase()), }, @@ -66,11 +83,11 @@ export default ({ getService }) => { }, }; - await createIndex({ callCluster, index }); - await createDocs({ callCluster, index, docs: originalDocs }); + await createIndex({ esClient, index }); + await createDocs({ esClient, index, docs: originalDocs }); // Test that unrelated index templates are unaffected - await callCluster('indices.putTemplate', { + await esClient.indices.putTemplate({ name: 'migration_test_a_template', body: { index_patterns: 'migration_test_a', @@ -82,7 +99,7 @@ export default ({ getService }) => { }); // Test that obsolete index templates get removed - await callCluster('indices.putTemplate', { + await esClient.indices.putTemplate({ name: 'migration_a_template', body: { index_patterns: index, @@ -93,29 +110,37 @@ export default ({ getService }) => { }, }); - assert.isTrue(await callCluster('indices.existsTemplate', { name: 'migration_a_template' })); + const migrationATemplate = await esClient.indices.existsTemplate({ + name: 'migration_a_template', + }); + expect(migrationATemplate.body).to.be.ok(); const result = await migrateIndex({ - callCluster, + esClient, index, migrations, mappingProperties, obsoleteIndexTemplatePattern: 'migration_a*', }); - assert.isFalse(await callCluster('indices.existsTemplate', { name: 'migration_a_template' })); - assert.isTrue( - await callCluster('indices.existsTemplate', { name: 'migration_test_a_template' }) - ); + const migrationATemplateAfter = await esClient.indices.existsTemplate({ + name: 'migration_a_template', + }); - assert.deepEqual(_.omit(result, 'elapsedMs'), { + expect(migrationATemplateAfter.body).not.to.be.ok(); + const migrationTestATemplateAfter = await esClient.indices.existsTemplate({ + name: 'migration_test_a_template', + }); + + expect(migrationTestATemplateAfter.body).to.be.ok(); + expect(_.omit(result, 'elapsedMs')).to.eql({ destIndex: '.migration-a_2', sourceIndex: '.migration-a_1', status: 'migrated', }); // The docs in the original index are unchanged - assert.deepEqual(await fetchDocs({ callCluster, index: `${index}_1` }), [ + expect(await fetchDocs(esClient, `${index}_1`)).to.eql([ { id: 'bar:i', type: 'bar', bar: { nomnom: 33 } }, { id: 'bar:o', type: 'bar', bar: { nomnom: 2 } }, { id: 'baz:u', type: 'baz', baz: { title: 'Terrific!' } }, @@ -124,7 +149,7 @@ export default ({ getService }) => { ]); // The docs in the alias have been migrated - assert.deepEqual(await fetchDocs({ callCluster, index }), [ + expect(await fetchDocs(esClient, index)).to.eql([ { id: 'bar:i', type: 'bar', @@ -171,7 +196,7 @@ export default ({ getService }) => { bar: { properties: { mynum: { type: 'integer' } } }, }; - const migrations = { + const migrations: Record = { foo: { '1.0.0': (doc) => set(doc, 'attributes.name', doc.attributes.name.toUpperCase()), }, @@ -182,19 +207,20 @@ export default ({ getService }) => { }, }; - await createIndex({ callCluster, index }); - await createDocs({ callCluster, index, docs: originalDocs }); + await createIndex({ esClient, index }); + await createDocs({ esClient, index, docs: originalDocs }); - await migrateIndex({ callCluster, index, migrations, mappingProperties }); + await migrateIndex({ esClient, index, migrations, mappingProperties }); + // @ts-expect-error name doesn't exist on mynum type mappingProperties.bar.properties.name = { type: 'keyword' }; migrations.foo['2.0.1'] = (doc) => set(doc, 'attributes.name', `${doc.attributes.name}v2`); migrations.bar['2.3.4'] = (doc) => set(doc, 'attributes.name', `NAME ${doc.id}`); - await migrateIndex({ callCluster, index, migrations, mappingProperties }); + await migrateIndex({ esClient, index, migrations, mappingProperties }); // The index for the initial migration has not been destroyed... - assert.deepEqual(await fetchDocs({ callCluster, index: `${index}_2` }), [ + expect(await fetchDocs(esClient, `${index}_2`)).to.eql([ { id: 'bar:i', type: 'bar', @@ -226,7 +252,7 @@ export default ({ getService }) => { ]); // The docs were migrated again... - assert.deepEqual(await fetchDocs({ callCluster, index }), [ + expect(await fetchDocs(esClient, index)).to.eql([ { id: 'bar:i', type: 'bar', @@ -266,48 +292,43 @@ export default ({ getService }) => { foo: { properties: { name: { type: 'text' } } }, }; - const migrations = { + const migrations: Record = { foo: { '1.0.0': (doc) => set(doc, 'attributes.name', 'LOTR'), }, }; - await createIndex({ callCluster, index }); - await createDocs({ callCluster, index, docs: originalDocs }); + await createIndex({ esClient, index }); + await createDocs({ esClient, index, docs: originalDocs }); const result = await Promise.all([ - migrateIndex({ callCluster, index, migrations, mappingProperties }), - migrateIndex({ callCluster, index, migrations, mappingProperties }), + migrateIndex({ esClient, index, migrations, mappingProperties }), + migrateIndex({ esClient, index, migrations, mappingProperties }), ]); // The polling instance and the migrating instance should both - // return a similar migraiton result. - assert.deepEqual( + // return a similar migration result. + expect( result + // @ts-expect-error destIndex exists only on MigrationResult status: 'migrated'; .map(({ status, destIndex }) => ({ status, destIndex })) - .sort((a) => (a.destIndex ? 0 : 1)), - [ - { status: 'migrated', destIndex: '.migration-c_2' }, - { status: 'skipped', destIndex: undefined }, - ] - ); + .sort((a) => (a.destIndex ? 0 : 1)) + ).to.eql([ + { status: 'migrated', destIndex: '.migration-c_2' }, + { status: 'skipped', destIndex: undefined }, + ]); + const { body } = await esClient.cat.indices({ index: '.migration-c*', format: 'json' }); // It only created the original and the dest - assert.deepEqual( - _.map( - await callCluster('cat.indices', { index: '.migration-c*', format: 'json' }), - 'index' - ).sort(), - ['.migration-c_1', '.migration-c_2'] - ); + expect(_.map(body, 'index').sort()).to.eql(['.migration-c_1', '.migration-c_2']); // The docs in the original index are unchanged - assert.deepEqual(await fetchDocs({ callCluster, index: `${index}_1` }), [ + expect(await fetchDocs(esClient, `${index}_1`)).to.eql([ { id: 'foo:lotr', type: 'foo', foo: { name: 'Lord of the Rings' } }, ]); // The docs in the alias have been migrated - assert.deepEqual(await fetchDocs({ callCluster, index }), [ + expect(await fetchDocs(esClient, index)).to.eql([ { id: 'foo:lotr', type: 'foo', @@ -320,38 +341,53 @@ export default ({ getService }) => { }); }; -async function createIndex({ callCluster, index }) { - await callCluster('indices.delete', { index: `${index}*`, ignore: [404] }); +async function createIndex({ esClient, index }: { esClient: ElasticsearchClient; index: string }) { + await esClient.indices.delete({ index: `${index}*` }, { ignore: [404] }); const properties = { type: { type: 'keyword' }, foo: { properties: { name: { type: 'keyword' } } }, bar: { properties: { nomnom: { type: 'integer' } } }, baz: { properties: { title: { type: 'keyword' } } }, }; - await callCluster('indices.create', { + await esClient.indices.create({ index, body: { mappings: { dynamic: 'strict', properties } }, }); } -async function createDocs({ callCluster, index, docs }) { - await callCluster('bulk', { +async function createDocs({ + esClient, + index, + docs, +}: { + esClient: ElasticsearchClient; + index: string; + docs: any[]; +}) { + await esClient.bulk({ body: docs.reduce((acc, doc) => { acc.push({ index: { _id: doc.id, _index: index } }); acc.push(_.omit(doc, 'id')); return acc; }, []), }); - await callCluster('indices.refresh', { index }); + await esClient.indices.refresh({ index }); } async function migrateIndex({ - callCluster, + esClient, index, migrations, mappingProperties, validateDoc, obsoleteIndexTemplatePattern, +}: { + esClient: ElasticsearchClient; + index: string; + migrations: Record; + mappingProperties: SavedObjectsTypeMappingDefinitions; + validateDoc?: (doc: any) => void; + obsoleteIndexTemplatePattern?: string; }) { const typeRegistry = new SavedObjectTypeRegistry(); const types = migrationsToTypes(migrations); @@ -361,17 +397,17 @@ async function migrateIndex({ kibanaVersion: '99.9.9', typeRegistry, validateDoc: validateDoc || _.noop, - log: { info: _.noop, debug: _.noop, warn: _.noop }, + log: getLogMock(), }); const migrator = new IndexMigrator({ - callCluster, + client: createMigrationEsClient(esClient, getLogMock()), documentMigrator, index, obsoleteIndexTemplatePattern, mappingProperties, batchSize: 10, - log: { info: _.noop, debug: _.noop, warn: _.noop }, + log: getLogMock(), pollInterval: 50, scrollDuration: '5m', serializer: new SavedObjectsSerializer(typeRegistry), @@ -380,21 +416,22 @@ async function migrateIndex({ return await migrator.migrate(); } -function migrationsToTypes(migrations) { - return Object.entries(migrations).map(([type, migrations]) => ({ +function migrationsToTypes( + migrations: Record +): SavedObjectsType[] { + return Object.entries(migrations).map(([type, migrationsMap]) => ({ name: type, hidden: false, namespaceType: 'single', mappings: { properties: {} }, - migrations: { ...migrations }, + migrations: { ...migrationsMap }, })); } -async function fetchDocs({ callCluster, index }) { - const { - hits: { hits }, - } = await callCluster('search', { index }); - return hits +async function fetchDocs(esClient: ElasticsearchClient, index: string) { + const { body } = await esClient.search>({ index }); + + return body.hits.hits .map((h) => ({ ...h._source, id: h._id, diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts index 2836cf100a432..6f4f92c6833f7 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts @@ -5,8 +5,12 @@ */ import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; -import { CoreSetup, Logger } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { + CoreSetup, + Logger, + SavedObjectsErrorHelpers, +} from '../../../../../../src/core/server'; import { APMConfig } from '../..'; import { TaskManagerSetupContract, @@ -110,7 +114,7 @@ export async function createApmTelemetry({ return data; } catch (err) { - if (err.output?.statusCode === 404) { + if (SavedObjectsErrorHelpers.isNotFoundError(err)) { // task has not run yet, so no saved object to return return {}; } diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts index e485fad09ba99..6cfe3d5b76266 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts @@ -48,7 +48,7 @@ export const getAgentHandler: RequestHandler { }); mockAgentService.getAgentStatusById = jest.fn().mockImplementation(() => { - throw Boom.notFound('Agent not found'); + SavedObjectsErrorHelpers.createGenericNotFoundError(); }); mockAgentService.getAgent = jest.fn().mockImplementation(() => { - throw Boom.notFound('Agent not found'); + SavedObjectsErrorHelpers.createGenericNotFoundError(); }); mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index 13ca51e1f2b39..b52c51ba789af 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -112,7 +112,7 @@ export class ManifestManager { // Cache the compressed body of the artifact this.cache.set(artifactId, Buffer.from(artifact.body, 'base64')); } catch (err) { - if (err.status === 409) { + if (this.savedObjectsClient.errors.isConflictError(err)) { this.logger.debug(`Tried to create artifact ${artifactId}, but it already exists.`); } else { return err; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts index 713d0cb85e2e8..525c3781be749 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts @@ -3,8 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import Boom from 'boom'; +import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; import moment from 'moment'; import { @@ -27,7 +26,7 @@ describe('ReindexActions', () => { beforeEach(() => { client = { - errors: null, + errors: SavedObjectsErrorHelpers, create: jest.fn(unimplemented('create')), bulkCreate: jest.fn(unimplemented('bulkCreate')), delete: jest.fn(unimplemented('delete')), @@ -306,7 +305,7 @@ describe('ReindexActions', () => { describe(`IndexConsumerType.${typeKey}`, () => { it('creates the lock doc if it does not exist and executes callback', async () => { expect.assertions(3); - client.get.mockRejectedValueOnce(Boom.notFound()); // mock no ML doc exists yet + client.get.mockRejectedValueOnce(SavedObjectsErrorHelpers.createGenericNotFoundError()); // mock no ML doc exists yet client.create.mockImplementationOnce((type: any, attributes: any, { id }: any) => Promise.resolve({ type, diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts index 54f9fe9d298f2..6d8afee1ff950 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts @@ -253,7 +253,7 @@ export const reindexActionsFactory = ( // The IndexGroup enum value (a string) serves as the ID of the lock doc return await client.get(REINDEX_OP_TYPE, indexGroup); } catch (e) { - if (e.isBoom && e.output.statusCode === 404) { + if (client.errors.isNotFoundError(e)) { return await client.create( REINDEX_OP_TYPE, { From ff9f06b880dab63e1e577433b012f4a2d6c792dc Mon Sep 17 00:00:00 2001 From: John Dorlus Date: Sun, 26 Jul 2020 10:04:09 -0400 Subject: [PATCH 02/17] The directory in the command was missing the /generated directory and would cause all definitions to be regenerated in the wrong place. (#72766) --- packages/kbn-spec-to-console/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kbn-spec-to-console/README.md b/packages/kbn-spec-to-console/README.md index 526ceef43e3cd..0328dec791320 100644 --- a/packages/kbn-spec-to-console/README.md +++ b/packages/kbn-spec-to-console/README.md @@ -23,10 +23,10 @@ At the root of the Kibana repository, run the following commands: ```sh # OSS -yarn spec_to_console -g "/rest-api-spec/src/main/resources/rest-api-spec/api/*" -d "src/plugins/console/server/lib/spec_definitions/json" +yarn spec_to_console -g "/rest-api-spec/src/main/resources/rest-api-spec/api/*" -d "src/plugins/console/server/lib/spec_definitions/json/generated" # X-pack -yarn spec_to_console -g "/x-pack/plugin/src/test/resources/rest-api-spec/api/*" -d "x-pack/plugins/console_extensions/server/lib/spec_definitions/json" +yarn spec_to_console -g "/x-pack/plugin/src/test/resources/rest-api-spec/api/*" -d "x-pack/plugins/console_extensions/server/lib/spec_definitions/json/generated" ``` ### Information used in Console that is not available in the REST spec From 55f55bfb547e3ce89174f3806605ed56f7fe0a4a Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Sun, 26 Jul 2020 12:41:22 -0500 Subject: [PATCH 03/17] [APM] Read body from indicesStats in upload-telemetry-data (#72732) add for transport request too --- .../apm/scripts/upload-telemetry-data/index.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts index a44fad82f20e6..10651d97f3c3d 100644 --- a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts +++ b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts @@ -87,13 +87,15 @@ async function uploadData() { return client.search(body as any).then((res) => res.body); }, indicesStats: (body) => { - return client.indices.stats(body as any); + return client.indices.stats(body as any).then((res) => res.body); }, transportRequest: ((params) => { - return client.transport.request({ - method: params.method, - path: params.path, - }); + return client.transport + .request({ + method: params.method, + path: params.path, + }) + .then((res) => res.body); }) as CollectTelemetryParams['transportRequest'], }, }); From 9656cbcbe776fc41e9c98e53bb714af9e448b714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Mon, 27 Jul 2020 00:45:52 +0200 Subject: [PATCH 04/17] Add default Elasticsearch credentials to docs (#72617) --- docs/developer/advanced/running-elasticsearch.asciidoc | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/developer/advanced/running-elasticsearch.asciidoc b/docs/developer/advanced/running-elasticsearch.asciidoc index 2361f805c7635..e5c86fafd1ce7 100644 --- a/docs/developer/advanced/running-elasticsearch.asciidoc +++ b/docs/developer/advanced/running-elasticsearch.asciidoc @@ -13,6 +13,10 @@ This will run a snapshot of {es} that is usually built nightly. Read more about ---- yarn es snapshot ---- +By default, two users are added to Elasticsearch: + + - A superuser with username: `elastic` and password: `changeme`, which can be used to log into Kibana with. + - A user with username: `kibana_system` and password `changeme`. This account is used by the Kibana server to authenticate itself to Elasticsearch, and to perform certain actions on behalf of the end user. These credentials should be specified in your kibana.yml as described in <> See all available options, like how to specify a specific license, with the `--help` flag. @@ -115,4 +119,4 @@ PUT _cluster/settings } ---- -Follow the cross-cluster search instructions for setting up index patterns to search across clusters (<>). \ No newline at end of file +Follow the cross-cluster search instructions for setting up index patterns to search across clusters (<>). From 122d7fe18fa6d82a76ccbc5cee4055ec7be9f428 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 27 Jul 2020 10:44:19 +0200 Subject: [PATCH 05/17] [Graph] Unskip graph tests (#72291) --- x-pack/test/functional/apps/graph/graph.ts | 28 ++++++++++--------- .../functional/page_objects/graph_page.ts | 12 +------- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/x-pack/test/functional/apps/graph/graph.ts b/x-pack/test/functional/apps/graph/graph.ts index 803e5e8f80d70..c2500dca78444 100644 --- a/x-pack/test/functional/apps/graph/graph.ts +++ b/x-pack/test/functional/apps/graph/graph.ts @@ -13,8 +13,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const browser = getService('browser'); - // FLAKY: https://github.com/elastic/kibana/issues/53749 - describe.skip('graph', function () { + describe('graph', function () { before(async () => { await browser.setWindowSize(1600, 1000); log.debug('load graph/secrepo data'); @@ -132,14 +131,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await buildGraph(); const { edges } = await PageObjects.graph.getGraphObjects(); - const blogAdminBlogEdge = edges.find( + await PageObjects.graph.isolateEdge('test', '/test/wp-admin/'); + + await PageObjects.graph.stopLayout(); + await PageObjects.common.sleep(1000); + const testTestWpAdminBlogEdge = edges.find( ({ sourceNode, targetNode }) => - sourceNode.label === '/blog/wp-admin/' && targetNode.label === 'blog' + targetNode.label === '/test/wp-admin/' && sourceNode.label === 'test' )!; - - await PageObjects.graph.isolateEdge(blogAdminBlogEdge); - - await PageObjects.graph.clickEdge(blogAdminBlogEdge); + await testTestWpAdminBlogEdge.element.click(); + await PageObjects.common.sleep(1000); + await PageObjects.graph.startLayout(); const vennTerm1 = await PageObjects.graph.getVennTerm1(); log.debug('vennTerm1 = ' + vennTerm1); @@ -156,11 +158,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const smallVennTerm2 = await PageObjects.graph.getSmallVennTerm2(); log.debug('smallVennTerm2 = ' + smallVennTerm2); - expect(vennTerm1).to.be('/blog/wp-admin/'); - expect(vennTerm2).to.be('blog'); - expect(smallVennTerm1).to.be('5'); - expect(smallVennTerm12).to.be(' (5) '); - expect(smallVennTerm2).to.be('8'); + expect(vennTerm1).to.be('/test/wp-admin/'); + expect(vennTerm2).to.be('test'); + expect(smallVennTerm1).to.be('4'); + expect(smallVennTerm12).to.be(' (4) '); + expect(smallVennTerm2).to.be('4'); }); it('should delete graph', async function () { diff --git a/x-pack/test/functional/page_objects/graph_page.ts b/x-pack/test/functional/page_objects/graph_page.ts index 0d3e2c10579f5..fe049327fe38b 100644 --- a/x-pack/test/functional/page_objects/graph_page.ts +++ b/x-pack/test/functional/page_objects/graph_page.ts @@ -83,10 +83,7 @@ export function GraphPageProvider({ getService, getPageObjects }: FtrProviderCon return [this.getPositionAsString(x1, y1), this.getPositionAsString(x2, y2)]; } - async isolateEdge(edge: Edge) { - const from = edge.sourceNode.label; - const to = edge.targetNode.label; - + async isolateEdge(from: string, to: string) { // select all nodes await testSubjects.click('graphSelectAll'); @@ -109,13 +106,6 @@ export function GraphPageProvider({ getService, getPageObjects }: FtrProviderCon await testSubjects.click('graphRemoveSelection'); } - async clickEdge(edge: Edge) { - await this.stopLayout(); - await PageObjects.common.sleep(1000); - await edge.element.click(); - await this.startLayout(); - } - async stopLayout() { if (await testSubjects.exists('graphPauseLayout')) { await testSubjects.click('graphPauseLayout'); From 6a53b0021e63383a6202a8f5814701e9a719b82a Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 27 Jul 2020 10:45:23 +0200 Subject: [PATCH 06/17] Convert functional vega tests to ts and unskip tests (#72238) * convert to ts and unskip tests * relax tests and remove unused imports * remove test exclusion * remove inspector test Co-authored-by: Elastic Machine --- .../{_vega_chart.js => _vega_chart.ts} | 35 +++++++--- .../page_objects/vega_chart_page.ts | 61 +++++++----------- .../screenshots/baseline/vega_chart.png | Bin 59257 -> 0 bytes .../baseline/vega_chart_filtered.png | Bin 59342 -> 0 bytes 4 files changed, 52 insertions(+), 44 deletions(-) rename test/functional/apps/visualize/{_vega_chart.js => _vega_chart.ts} (59%) delete mode 100644 test/functional/screenshots/baseline/vega_chart.png delete mode 100644 test/functional/screenshots/baseline/vega_chart_filtered.png diff --git a/test/functional/apps/visualize/_vega_chart.js b/test/functional/apps/visualize/_vega_chart.ts similarity index 59% rename from test/functional/apps/visualize/_vega_chart.js rename to test/functional/apps/visualize/_vega_chart.ts index c530c6f823133..6c0b77411ae99 100644 --- a/test/functional/apps/visualize/_vega_chart.js +++ b/test/functional/apps/visualize/_vega_chart.ts @@ -18,9 +18,17 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { - const PageObjects = getPageObjects(['timePicker', 'visualize', 'visChart', 'vegaChart']); +// eslint-disable-next-line import/no-default-export +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const PageObjects = getPageObjects([ + 'timePicker', + 'visualize', + 'visChart', + 'visEditor', + 'vegaChart', + ]); const filterBar = getService('filterBar'); const log = getService('log'); @@ -30,13 +38,15 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visualize.navigateToNewVisualization(); log.debug('clickVega'); await PageObjects.visualize.clickVega(); + await PageObjects.visChart.waitForVisualizationRenderingStabilized(); }); describe('vega chart', () => { describe('initial render', () => { - it.skip('should have some initial vega spec text', async function () { + it('should have some initial vega spec text', async function () { const vegaSpec = await PageObjects.vegaChart.getSpec(); - expect(vegaSpec).to.contain('{').and.to.contain('data'); + expect(vegaSpec).to.contain('{'); + expect(vegaSpec).to.contain('data'); expect(vegaSpec.length).to.be.above(500); }); @@ -44,7 +54,8 @@ export default function ({ getService, getPageObjects }) { const view = await PageObjects.vegaChart.getViewContainer(); expect(view).to.be.ok(); const size = await view.getSize(); - expect(size).to.have.property('width').and.to.have.property('height'); + expect(size).to.have.property('width'); + expect(size).to.have.property('height'); expect(size.width).to.be.above(0); expect(size.height).to.be.above(0); @@ -63,10 +74,18 @@ export default function ({ getService, getPageObjects }) { await filterBar.removeAllFilters(); }); - it.skip('should render different data in response to filter change', async function () { - await PageObjects.vegaChart.expectVisToMatchScreenshot('vega_chart'); + it('should render different data in response to filter change', async function () { + await PageObjects.vegaChart.typeInSpec('"config": { "kibana": {"renderer": "svg"} },'); + await PageObjects.visEditor.clickGo(); + await PageObjects.visChart.waitForVisualizationRenderingStabilized(); + const fullDataLabels = await PageObjects.vegaChart.getYAxisLabels(); + expect(fullDataLabels[0]).to.eql('0'); + expect(fullDataLabels[fullDataLabels.length - 1]).to.eql('1,600'); await filterBar.addFilter('@tags.raw', 'is', 'error'); - await PageObjects.vegaChart.expectVisToMatchScreenshot('vega_chart_filtered'); + await PageObjects.visChart.waitForVisualizationRenderingStabilized(); + const filteredDataLabels = await PageObjects.vegaChart.getYAxisLabels(); + expect(filteredDataLabels[0]).to.eql('0'); + expect(filteredDataLabels[filteredDataLabels.length - 1]).to.eql('90'); }); }); }); diff --git a/test/functional/page_objects/vega_chart_page.ts b/test/functional/page_objects/vega_chart_page.ts index 488f4cfd0d0ce..b9906911b00f1 100644 --- a/test/functional/page_objects/vega_chart_page.ts +++ b/test/functional/page_objects/vega_chart_page.ts @@ -17,20 +17,17 @@ * under the License. */ -import expect from '@kbn/expect'; +import { Key } from 'selenium-webdriver'; import { FtrProviderContext } from '../ftr_provider_context'; export function VegaChartPageProvider({ getService, getPageObjects, - updateBaselines, }: FtrProviderContext & { updateBaselines: boolean }) { const find = getService('find'); const testSubjects = getService('testSubjects'); const browser = getService('browser'); - const screenshot = getService('screenshots'); - const log = getService('log'); - const { visEditor, visChart } = getPageObjects(['visEditor', 'visChart']); + const { common } = getPageObjects(['common']); class VegaChartPage { public async getSpec() { @@ -45,6 +42,19 @@ export function VegaChartPageProvider({ return linesText.join('\n'); } + public async typeInSpec(text: string) { + const editor = await testSubjects.find('vega-editor'); + const textarea = await editor.findByClassName('ace_content'); + await textarea.click(); + let repeats = 20; + while (--repeats > 0) { + await browser.pressKeys(Key.ARROW_UP); + await common.sleep(50); + } + await browser.pressKeys(Key.ARROW_RIGHT); + await browser.pressKeys(text); + } + public async getViewContainer() { return await find.byCssSelector('div.vgaVis__view'); } @@ -53,37 +63,16 @@ export function VegaChartPageProvider({ return await find.byCssSelector('div.vgaVis__controls'); } - /** - * Removes chrome and takes a small screenshot of a vis to compare against a baseline. - * @param {string} name The name of the baseline image. - * @param {object} opts Options object. - * @param {number} opts.threshold Threshold for allowed variance when comparing images. - */ - public async expectVisToMatchScreenshot(name: string, opts = { threshold: 0.05 }) { - log.debug(`expectVisToMatchScreenshot(${name})`); - - // Collapse sidebar and inject some CSS to hide the nav so we have a focused screenshot - await visEditor.clickEditorSidebarCollapse(); - await visChart.waitForVisualizationRenderingStabilized(); - await browser.execute(` - var el = document.createElement('style'); - el.id = '__data-test-style'; - el.innerHTML = '[data-test-subj="headerGlobalNav"] { display: none; } '; - el.innerHTML += '[data-test-subj="top-nav"] { display: none; } '; - el.innerHTML += '[data-test-subj="experimentalVisInfo"] { display: none; } '; - document.body.appendChild(el); - `); - - const percentDifference = await screenshot.compareAgainstBaseline(name, updateBaselines); - - // Reset the chart to its original state - await browser.execute(` - var el = document.getElementById('__data-test-style'); - document.body.removeChild(el); - `); - await visEditor.clickEditorSidebarCollapse(); - await visChart.waitForVisualizationRenderingStabilized(); - expect(percentDifference).to.be.lessThan(opts.threshold); + public async getYAxisLabels() { + const chart = await testSubjects.find('visualizationLoader'); + const yAxis = await chart.findByCssSelector('[aria-label^="Y-axis"]'); + const tickGroup = await yAxis.findByClassName('role-axis-label'); + const labels = await tickGroup.findAllByCssSelector('text'); + const labelTexts: string[] = []; + for (const label of labels) { + labelTexts.push(await label.getVisibleText()); + } + return labelTexts; } } diff --git a/test/functional/screenshots/baseline/vega_chart.png b/test/functional/screenshots/baseline/vega_chart.png deleted file mode 100644 index 5288bd9c7b924b4cd94885f45aa28b3543aa3257..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 59257 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 Date: Mon, 27 Jul 2020 11:58:11 +0200 Subject: [PATCH 07/17] Remove flaky note from gauge tests (#73240) --- test/functional/apps/visualize/_gauge_chart.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/functional/apps/visualize/_gauge_chart.js b/test/functional/apps/visualize/_gauge_chart.js index aa94e596319c2..0f870b1fb545f 100644 --- a/test/functional/apps/visualize/_gauge_chart.js +++ b/test/functional/apps/visualize/_gauge_chart.js @@ -26,7 +26,6 @@ export default function ({ getService, getPageObjects }) { const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects(['visualize', 'visEditor', 'visChart', 'timePicker']); - // FLAKY: https://github.com/elastic/kibana/issues/45089 describe('gauge chart', function indexPatternCreation() { async function initGaugeVis() { log.debug('navigateToApp visualize'); From 9d5b1bf20b5c5f48f9b9c6e4b8bfaee426e6f364 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Mon, 27 Jul 2020 12:10:16 +0100 Subject: [PATCH 08/17] simplified buffer tests to reduce flakyness (#73024) Co-authored-by: Elastic Machine --- .../server/lib/bulk_operation_buffer.test.ts | 80 ++++++++----------- 1 file changed, 32 insertions(+), 48 deletions(-) diff --git a/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts b/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts index 3a21f622cec17..f32a755515a95 100644 --- a/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts +++ b/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts @@ -33,8 +33,7 @@ function errorAttempts(task: TaskInstance): Err { +describe('Bulk Operation Buffer', () => { describe('createBuffer()', () => { test('batches up multiple Operation calls', async () => { const bulkUpdate: jest.Mocked> = jest.fn( @@ -67,8 +66,6 @@ describe.skip('Bulk Operation Buffer', () => { const task2 = createTask(); const task3 = createTask(); const task4 = createTask(); - const task5 = createTask(); - const task6 = createTask(); return new Promise((resolve) => { Promise.all([bufferedUpdate(task1), bufferedUpdate(task2)]).then((_) => { @@ -79,22 +76,18 @@ describe.skip('Bulk Operation Buffer', () => { setTimeout(() => { // on next tick - setTimeout(() => { - // on next tick - expect(bulkUpdate).toHaveBeenCalledTimes(2); - Promise.all([bufferedUpdate(task5), bufferedUpdate(task6)]).then((_) => { - expect(bulkUpdate).toHaveBeenCalledTimes(3); - expect(bulkUpdate).toHaveBeenCalledWith([task5, task6]); - resolve(); - }); - }, bufferMaxDuration + 1); - expect(bulkUpdate).toHaveBeenCalledTimes(1); Promise.all([bufferedUpdate(task3), bufferedUpdate(task4)]).then((_) => { expect(bulkUpdate).toHaveBeenCalledTimes(2); expect(bulkUpdate).toHaveBeenCalledWith([task3, task4]); }); - }, bufferMaxDuration + 1); + + setTimeout(() => { + // on next tick + expect(bulkUpdate).toHaveBeenCalledTimes(2); + resolve(); + }, bufferMaxDuration * 1.1); + }, bufferMaxDuration * 1.1); }); }); @@ -103,8 +96,9 @@ describe.skip('Bulk Operation Buffer', () => { return Promise.resolve(tasks.map(incrementAttempts)); }); + const bufferMaxDuration = 1000; const bufferedUpdate = createBuffer(bulkUpdate, { - bufferMaxDuration: 100, + bufferMaxDuration, bufferMaxOperations: 2, }); @@ -114,26 +108,19 @@ describe.skip('Bulk Operation Buffer', () => { const task4 = createTask(); const task5 = createTask(); - return new Promise((resolve) => { - bufferedUpdate(task1); - bufferedUpdate(task2); - bufferedUpdate(task3); - bufferedUpdate(task4); - - setTimeout(() => { - expect(bulkUpdate).toHaveBeenCalledTimes(2); - expect(bulkUpdate).toHaveBeenCalledWith([task1, task2]); - expect(bulkUpdate).toHaveBeenCalledWith([task3, task4]); - - setTimeout(() => { - expect(bulkUpdate).toHaveBeenCalledTimes(2); - bufferedUpdate(task5).then((_) => { - expect(bulkUpdate).toHaveBeenCalledTimes(3); - expect(bulkUpdate).toHaveBeenCalledWith([task5]); - resolve(); - }); - }, 50); - }, 50); + return Promise.all([ + bufferedUpdate(task1), + bufferedUpdate(task2), + bufferedUpdate(task3), + bufferedUpdate(task4), + ]).then(() => { + expect(bulkUpdate).toHaveBeenCalledTimes(2); + expect(bulkUpdate).toHaveBeenCalledWith([task1, task2]); + expect(bulkUpdate).toHaveBeenCalledWith([task3, task4]); + return bufferedUpdate(task5).then((_) => { + expect(bulkUpdate).toHaveBeenCalledTimes(3); + expect(bulkUpdate).toHaveBeenCalledWith([task5]); + }); }); }); @@ -153,29 +140,26 @@ describe.skip('Bulk Operation Buffer', () => { const task3 = createTask(); const task4 = createTask(); - return new Promise((resolve) => { - bufferedUpdate(task1); - bufferedUpdate(task2); - - setTimeout(() => { - expect(bulkUpdate).toHaveBeenCalledTimes(1); - expect(bulkUpdate).toHaveBeenCalledWith([task1, task2]); + return Promise.all([bufferedUpdate(task1), bufferedUpdate(task2)]).then(() => { + expect(bulkUpdate).toHaveBeenCalledTimes(1); + expect(bulkUpdate).toHaveBeenCalledWith([task1, task2]); - bufferedUpdate(task3); - bufferedUpdate(task4); + return new Promise((resolve) => { + const futureUpdates = Promise.all([bufferedUpdate(task3), bufferedUpdate(task4)]); setTimeout(() => { expect(bulkUpdate).toHaveBeenCalledTimes(1); - setTimeout(() => { + futureUpdates.then(() => { expect(bulkUpdate).toHaveBeenCalledTimes(2); expect(bulkUpdate).toHaveBeenCalledWith([task3, task4]); resolve(); - }, bufferMaxDuration / 2); + }); }, bufferMaxDuration / 2); - }, bufferMaxDuration + 1); + }); }); }); + test('handles both resolutions and rejections at individual task level', async (done) => { const bulkUpdate: jest.Mocked> = jest.fn( ([task1, task2, task3]) => { From d3ddcd2027442dd11136b4307f65ba4e693654de Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Mon, 27 Jul 2020 08:18:36 -0500 Subject: [PATCH 09/17] [APM] APM & Observability plugin lint improvements (#72702) * [APM] APM & Observability plugin lint improvements This is a large change, but most of it is automatic `eslint --fix` changes. * Apply the same ESLint ovderrides in APM and Observability plugins. * Remove the `no-unused-vars` rule. We can turn on the TypeScript check if needed. * Check both JS and TS files. * Add a rule for react function component definitions * Upgrade eslint-plugin-react to include that rule --- .eslintrc.js | 21 +-- package.json | 2 +- .../views/data/components/data_view.test.tsx | 4 +- .../plugins/apm/e2e/cypress/plugins/index.js | 2 + .../plugins/apm/public/application/index.tsx | 10 +- .../app/ErrorGroupOverview/List/index.tsx | 4 +- .../app/ErrorGroupOverview/index.tsx | 4 +- .../Breakdowns/BreakdownFilter.tsx | 6 +- .../Breakdowns/BreakdownGroup.tsx | 6 +- .../app/RumDashboard/ChartWrapper/index.tsx | 9 +- .../RumDashboard/Charts/PageLoadDistChart.tsx | 1 + .../Charts/VisitorBreakdownChart.tsx | 4 +- .../PageLoadDistribution/BreakdownSeries.tsx | 8 +- .../PercentileAnnotations.tsx | 10 +- .../PageLoadDistribution/index.tsx | 4 +- .../app/RumDashboard/PageViewsTrend/index.tsx | 4 +- .../app/RumDashboard/RumDashboard.tsx | 4 +- .../app/RumDashboard/RumHeader/index.tsx | 24 ++-- .../RumDashboard/VisitorBreakdown/index.tsx | 4 +- .../app/ServiceMap/EmptyBanner.test.tsx | 36 +++-- .../app/ServiceMap/LoadingOverlay.tsx | 48 +++---- .../app/ServiceMap/Popover/Contents.tsx | 11 +- .../components/app/ServiceMap/index.test.tsx | 6 +- .../components/app/ServiceMap/index.tsx | 4 +- .../app/ServiceNodeOverview/index.tsx | 4 +- .../AgentConfigurations/List/index.tsx | 4 +- .../CustomLink/CreateCustomLinkButton.tsx | 22 ++- .../CustomLinkFlyout/Documentation.tsx | 16 ++- .../CustomLinkFlyout/FiltersSection.tsx | 38 ++--- .../CustomLinkFlyout/FlyoutFooter.tsx | 6 +- .../CustomLinkFlyout/LinkPreview.tsx | 4 +- .../CustomLinkFlyout/LinkSection.tsx | 9 +- .../CustomLink/CustomLinkFlyout/index.tsx | 6 +- .../CustomLink/CustomLinkTable.tsx | 39 +++--- .../CustomizeUI/CustomLink/EmptyPrompt.tsx | 6 +- .../Settings/CustomizeUI/CustomLink/Title.tsx | 62 +++++---- .../Settings/CustomizeUI/CustomLink/index.tsx | 4 +- .../app/Settings/CustomizeUI/index.tsx | 4 +- .../anomaly_detection/add_environments.tsx | 6 +- .../app/Settings/anomaly_detection/index.tsx | 4 +- .../Settings/anomaly_detection/jobs_list.tsx | 4 +- .../public/components/app/Settings/index.tsx | 6 +- .../public/components/app/TraceLink/index.tsx | 4 +- .../TransactionDetails/Distribution/index.tsx | 10 +- .../WaterfallWithSummmary/ErrorCount.tsx | 38 ++--- .../SpanFlyout/TruncateHeightSection.tsx | 10 +- .../Waterfall/WaterfallFlyout.tsx | 7 +- .../Waterfall/WaterfallItem.tsx | 10 +- .../WaterfallContainer/Waterfall/index.tsx | 10 +- .../WaterfallWithSummmary/index.tsx | 6 +- .../components/shared/ApmHeader/index.tsx | 42 +++--- .../DatePicker/__test__/DatePicker.test.tsx | 30 ++-- .../public/components/shared/EmptyMessage.tsx | 6 +- .../shared/EnvironmentBadge/index.tsx | 4 +- .../shared/EnvironmentFilter/index.tsx | 4 +- .../public/components/shared/EuiTabLink.tsx | 4 +- .../shared/HeightRetainer/index.tsx | 9 +- .../components/shared/KueryBar/index.tsx | 1 - .../components/shared/LicensePrompt/index.tsx | 4 +- .../Links/DiscoverLinks/DiscoverErrorLink.tsx | 13 +- .../Links/DiscoverLinks/DiscoverSpanLink.tsx | 12 +- .../DiscoverLinks/DiscoverTransactionLink.tsx | 12 +- .../Links/MachineLearningLinks/MLJobLink.tsx | 9 +- .../shared/Links/apm/ErrorDetailLink.tsx | 4 +- .../shared/Links/apm/ErrorOverviewLink.tsx | 4 +- .../components/shared/Links/apm/HomeLink.tsx | 4 +- .../shared/Links/apm/MetricOverviewLink.tsx | 4 +- .../shared/Links/apm/ServiceMapLink.tsx | 4 +- .../apm/ServiceNodeMetricOverviewLink.tsx | 6 +- .../Links/apm/ServiceNodeOverviewLink.tsx | 4 +- .../shared/Links/apm/ServiceOverviewLink.tsx | 4 +- .../shared/Links/apm/SettingsLink.tsx | 4 +- .../shared/Links/apm/TraceOverviewLink.tsx | 4 +- .../Links/apm/TransactionDetailLink.tsx | 6 +- .../Links/apm/TransactionOverviewLink.tsx | 4 +- .../LocalUIFilters/Filter/FilterBadgeList.tsx | 40 +++--- .../Filter/FilterTitleButton.tsx | 4 +- .../shared/LocalUIFilters/Filter/index.tsx | 11 +- .../ServiceNameFilter/index.tsx | 4 +- .../TransactionTypeFilter/index.tsx | 4 +- .../shared/LocalUIFilters/index.tsx | 6 +- .../components/shared/MetadataTable/index.tsx | 32 +++-- .../shared/SelectWithPlaceholder/index.tsx | 1 + .../PopoverExpression/index.tsx | 4 +- .../shared/Stacktrace/FrameHeading.tsx | 4 +- .../shared/Stacktrace/Variables.tsx | 4 +- .../shared/Summary/DurationSummaryItem.tsx | 8 +- .../Summary/ErrorCountSummaryItemBadge.tsx | 4 +- .../shared/Summary/TransactionSummary.tsx | 12 +- .../components/shared/Summary/index.tsx | 4 +- .../CustomLink/CustomLinkPopover.tsx | 6 +- .../CustomLink/CustomLinkSection.tsx | 42 +++--- .../CustomLink/ManageCustomLink.tsx | 74 +++++----- .../CustomLink/index.tsx | 6 +- .../TransactionActionMenu.tsx | 34 ++--- .../TransactionBreakdownGraph/index.tsx | 4 +- .../TransactionBreakdownKpiList.tsx | 11 +- .../shared/TransactionBreakdown/index.tsx | 4 +- .../charts/CustomPlot/AnnotationsPlot.tsx | 4 +- .../ErroneousTransactionsRateChart/index.tsx | 4 +- .../components/shared/charts/Legend/index.tsx | 6 +- .../charts/Timeline/Marker/AgentMarker.tsx | 4 +- .../charts/Timeline/Marker/ErrorMarker.tsx | 4 +- .../shared/charts/Timeline/Marker/index.tsx | 4 +- .../shared/charts/Timeline/TimelineAxis.tsx | 6 +- .../shared/charts/Timeline/VerticalLines.tsx | 6 +- .../ChoroplethMap/ChoroplethToolTip.tsx | 10 +- .../TransactionCharts/ChoroplethMap/index.tsx | 4 +- .../DurationByCountryMap/index.tsx | 4 +- .../TransactionLineChart/index.tsx | 4 +- .../apm/public/context/ChartsSyncContext.tsx | 6 +- .../MockUrlParamsContextProvider.tsx | 6 +- .../public/utils/getRangeFromTimeSeries.ts | 4 +- .../plugins/apm/public/utils/testHelpers.tsx | 8 +- .../public/application/index.tsx | 4 +- .../components/app/chart_container/index.tsx | 6 +- .../components/app/empty_section/index.tsx | 4 +- .../public/components/app/header/index.tsx | 6 +- .../app/ingest_manager_panel/index.tsx | 4 +- .../components/app/layout/with_header.tsx | 30 ++-- .../public/components/app/news_feed/index.tsx | 8 +- .../public/components/app/resources/index.tsx | 4 +- .../components/app/section/alerts/index.tsx | 4 +- .../components/app/section/apm/index.tsx | 6 +- .../app/section/error_panel/index.tsx | 4 +- .../public/components/app/section/index.tsx | 4 +- .../components/app/section/logs/index.tsx | 6 +- .../components/app/section/metrics/index.tsx | 12 +- .../components/app/section/uptime/index.tsx | 12 +- .../components/app/styled_stat/index.tsx | 4 +- .../components/shared/action_menu/index.tsx | 74 +++++----- .../components/shared/data_picker/index.tsx | 4 +- .../observability/public/pages/home/index.tsx | 4 +- .../public/pages/landing/index.tsx | 4 +- .../public/pages/overview/index.tsx | 8 +- .../pages/overview/loading_observability.tsx | 4 +- .../public/typings/eui_styled_components.tsx | 24 ++-- .../components/rules/mitre/index.tsx | 2 +- .../tags_filter_popover.tsx | 1 + yarn.lock | 130 +++++++++++------- 140 files changed, 824 insertions(+), 733 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index e2674e8d7b407..c9f9d96f9ddae 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -771,19 +771,22 @@ module.exports = { }, /** - * APM overrides + * APM and Observability overrides */ { - files: ['x-pack/plugins/apm/**/*.js'], + files: [ + 'x-pack/plugins/apm/**/*.{js,mjs,ts,tsx}', + 'x-pack/plugins/observability/**/*.{js,mjs,ts,tsx}', + ], rules: { - 'no-unused-vars': ['error', { ignoreRestSiblings: true }], 'no-console': ['warn', { allow: ['error'] }], - }, - }, - { - plugins: ['react-hooks'], - files: ['x-pack/plugins/apm/**/*.{ts,tsx}'], - rules: { + 'react/function-component-definition': [ + 'warn', + { + namedComponents: 'function-declaration', + unnamedComponents: 'arrow-function', + }, + ], 'react-hooks/rules-of-hooks': 'error', // Checks rules of Hooks 'react-hooks/exhaustive-deps': ['error', { additionalHooks: '^useFetcher$' }], }, diff --git a/package.json b/package.json index 594f0ce583987..ee91c59a8fda6 100644 --- a/package.json +++ b/package.json @@ -435,7 +435,7 @@ "eslint-plugin-node": "^11.0.0", "eslint-plugin-prefer-object-spread": "^1.2.1", "eslint-plugin-prettier": "^3.1.3", - "eslint-plugin-react": "^7.17.0", + "eslint-plugin-react": "^7.20.3", "eslint-plugin-react-hooks": "^4.0.4", "eslint-plugin-react-perf": "^3.2.3", "exit-hook": "^2.2.0", diff --git a/src/plugins/inspector/public/views/data/components/data_view.test.tsx b/src/plugins/inspector/public/views/data/components/data_view.test.tsx index 2772069d36877..bd78bca42c479 100644 --- a/src/plugins/inspector/public/views/data/components/data_view.test.tsx +++ b/src/plugins/inspector/public/views/data/components/data_view.test.tsx @@ -51,13 +51,13 @@ describe('Inspector Data View', () => { }); it('should render loading state', () => { - const component = mountWithIntl(); + const component = mountWithIntl(); // eslint-disable-line react/jsx-pascal-case expect(component).toMatchSnapshot(); }); it('should render empty state', async () => { - const component = mountWithIntl(); + const component = mountWithIntl(); // eslint-disable-line react/jsx-pascal-case const tabularLoader = Promise.resolve(null); adapters.data.setTabularLoader(() => tabularLoader); await tabularLoader; diff --git a/x-pack/plugins/apm/e2e/cypress/plugins/index.js b/x-pack/plugins/apm/e2e/cypress/plugins/index.js index 540b887d55df5..c5529c747adcd 100644 --- a/x-pack/plugins/apm/e2e/cypress/plugins/index.js +++ b/x-pack/plugins/apm/e2e/cypress/plugins/index.js @@ -29,6 +29,8 @@ module.exports = (on) => { // readFileMaybe on('task', { + // ESLint thinks this is a react component for some reason. + // eslint-disable-next-line react/function-component-definition readFileMaybe(filename) { if (fs.existsSync(filename)) { return fs.readFileSync(filename, 'utf8'); diff --git a/x-pack/plugins/apm/public/application/index.tsx b/x-pack/plugins/apm/public/application/index.tsx index c39afe6da215e..0c9c6eb86225b 100644 --- a/x-pack/plugins/apm/public/application/index.tsx +++ b/x-pack/plugins/apm/public/application/index.tsx @@ -37,7 +37,7 @@ const MainContainer = styled.div` height: 100%; `; -const App = () => { +function App() { const [darkMode] = useUiSetting$('theme:darkMode'); return ( @@ -59,9 +59,9 @@ const App = () => { ); -}; +} -const ApmAppRoot = ({ +function ApmAppRoot({ core, deps, routerHistory, @@ -71,7 +71,7 @@ const ApmAppRoot = ({ deps: ApmPluginSetupDeps; routerHistory: typeof history; config: ConfigSchema; -}) => { +}) { const i18nCore = core.i18n; const plugins = deps; const apmPluginContextValue = { @@ -111,7 +111,7 @@ const ApmAppRoot = ({ ); -}; +} /** * This module is rendered asynchronously in the Kibana platform. diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx index 1096c0c77db30..5c16bf0f324be 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx @@ -53,7 +53,7 @@ interface Props { items: ErrorGroupListAPIResponse; } -const ErrorGroupList: React.FC = (props) => { +function ErrorGroupList(props: Props) { const { items } = props; const { urlParams } = useUrlParams(); const { serviceName } = urlParams; @@ -213,6 +213,6 @@ const ErrorGroupList: React.FC = (props) => { sortItems={false} /> ); -}; +} export { ErrorGroupList }; diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx index b9a28c1c1841f..fe2303d645ec9 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx @@ -22,7 +22,7 @@ import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { ErrorDistribution } from '../ErrorGroupDetails/Distribution'; import { ErrorGroupList } from './List'; -const ErrorGroupOverview: React.FC = () => { +function ErrorGroupOverview() { const { urlParams, uiFilters } = useUrlParams(); const { serviceName, start, end, sortField, sortDirection } = urlParams; @@ -123,6 +123,6 @@ const ErrorGroupOverview: React.FC = () => { ); -}; +} export { ErrorGroupOverview }; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx index 332cf40a465f9..7e5e7cdc53c55 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx @@ -20,11 +20,11 @@ interface Props { onBreakdownChange: (values: BreakdownItem[]) => void; } -export const BreakdownFilter = ({ +export function BreakdownFilter({ id, selectedBreakdowns, onBreakdownChange, -}: Props) => { +}: Props) { const categories: BreakdownItem[] = [ { name: 'Browser', @@ -65,4 +65,4 @@ export const BreakdownFilter = ({ }} /> ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownGroup.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownGroup.tsx index 5bf84b6c918c5..d4f80667ce98b 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownGroup.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownGroup.tsx @@ -22,12 +22,12 @@ export interface BreakdownGroupProps { onChange: (values: BreakdownItem[]) => void; } -export const BreakdownGroup = ({ +export function BreakdownGroup({ id, disabled, onChange, items, -}: BreakdownGroupProps) => { +}: BreakdownGroupProps) { const [isOpen, setIsOpen] = useState(false); const [activeItems, setActiveItems] = useState(items); @@ -97,4 +97,4 @@ export const BreakdownGroup = ({ ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/ChartWrapper/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/ChartWrapper/index.tsx index a3cfbb28abee2..970365779a0a2 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/ChartWrapper/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/ChartWrapper/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, HTMLAttributes } from 'react'; +import React, { HTMLAttributes, ReactNode } from 'react'; import { EuiErrorBoundary, EuiFlexGroup, @@ -13,6 +13,7 @@ import { } from '@elastic/eui'; interface Props { + children?: ReactNode; /** * Height for the chart */ @@ -27,12 +28,12 @@ interface Props { 'aria-label'?: string; } -export const ChartWrapper: FC = ({ +export function ChartWrapper({ loading = false, height = '100%', children, ...rest -}) => { +}: Props) { const opacity = loading === true ? 0.3 : 1; return ( @@ -60,4 +61,4 @@ export const ChartWrapper: FC = ({ )} ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx index 6c5b539fcecfa..b2b5e66d06ac6 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx @@ -70,6 +70,7 @@ export function PageLoadDistChart({ onPercentileChange(minX, maxX); }; + // eslint-disable-next-line react/function-component-definition const headerFormatter: TooltipValueFormatter = (tooltip: TooltipValue) => { return (
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/VisitorBreakdownChart.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/VisitorBreakdownChart.tsx index 1e28fde4aa2b4..9f9ffdf7168b8 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/VisitorBreakdownChart.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/VisitorBreakdownChart.tsx @@ -29,7 +29,7 @@ interface Props { }>; } -export const VisitorBreakdownChart = ({ options }: Props) => { +export function VisitorBreakdownChart({ options }: Props) { const [darkMode] = useUiSetting$('theme:darkMode'); return ( @@ -93,4 +93,4 @@ export const VisitorBreakdownChart = ({ options }: Props) => { ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx index 0c47ad24128ef..475a235ef5eed 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useEffect } from 'react'; import { CurveType, LineSeries, ScaleType } from '@elastic/charts'; +import React, { useEffect } from 'react'; import { PercentileRange } from './index'; import { useBreakdowns } from './use_breakdowns'; @@ -16,12 +16,12 @@ interface Props { onLoadingChange: (loading: boolean) => void; } -export const BreakdownSeries: FC = ({ +export function BreakdownSeries({ field, value, percentileRange, onLoadingChange, -}) => { +}: Props) { const { data, status } = useBreakdowns({ field, value, @@ -47,4 +47,4 @@ export const BreakdownSeries: FC = ({ ))} ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/PercentileAnnotations.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/PercentileAnnotations.tsx index 9066dd73159b1..407ec42f03ff5 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/PercentileAnnotations.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/PercentileAnnotations.tsx @@ -33,7 +33,7 @@ const PercentileMarker = styled.span` bottom: 205px; `; -export const PercentileAnnotations = ({ percentiles }: Props) => { +export function PercentileAnnotations({ percentiles }: Props) { const dataValues = generateAnnotationData(percentiles) ?? []; const style: Partial = { @@ -44,17 +44,17 @@ export const PercentileAnnotations = ({ percentiles }: Props) => { }, }; - const PercentileTooltip = ({ + function PercentileTooltip({ annotation, }: { annotation: LineAnnotationDatum; - }) => { + }) { return ( {annotation.details}th Percentile ); - }; + } return ( <> @@ -82,4 +82,4 @@ export const PercentileAnnotations = ({ percentiles }: Props) => { ))} ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx index adeff2b31fd93..c7545ff9a2764 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx @@ -24,7 +24,7 @@ export interface PercentileRange { max?: number | null; } -export const PageLoadDistribution = () => { +export function PageLoadDistribution() { const { urlParams, uiFilters } = useUrlParams(); const { start, end, serviceName } = urlParams; @@ -115,4 +115,4 @@ export const PageLoadDistribution = () => { />
); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx index c6ef319f8a666..0f43c0ddf540d 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx @@ -13,7 +13,7 @@ import { BreakdownFilter } from '../Breakdowns/BreakdownFilter'; import { PageViewsChart } from '../Charts/PageViewsChart'; import { BreakdownItem } from '../../../../../typings/ui_filters'; -export const PageViewsTrend = () => { +export function PageViewsTrend() { const { urlParams, uiFilters } = useUrlParams(); const { start, end, serviceName } = urlParams; @@ -68,4 +68,4 @@ export const PageViewsTrend = () => { ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx index 2eb79257334d7..8c8164972328f 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx @@ -18,7 +18,7 @@ import { PageLoadDistribution } from './PageLoadDistribution'; import { I18LABELS } from './translations'; import { VisitorBreakdown } from './VisitorBreakdown'; -export const RumDashboard = () => { +export function RumDashboard() { return ( @@ -54,4 +54,4 @@ export const RumDashboard = () => { ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHeader/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHeader/index.tsx index b1ff38fdd2d79..6b3fcb3b03466 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHeader/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHeader/index.tsx @@ -5,16 +5,18 @@ */ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React from 'react'; +import React, { ReactNode } from 'react'; import { DatePicker } from '../../../shared/DatePicker'; -export const RumHeader: React.FC = ({ children }) => ( - <> - - {children} - - - - - -); +export function RumHeader({ children }: { children: ReactNode }) { + return ( + <> + + {children} + + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdown/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdown/index.tsx index 2e17e27587b63..5c68ebb1667ab 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdown/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdown/index.tsx @@ -11,7 +11,7 @@ import { VisitorBreakdownLabel } from '../translations'; import { useFetcher } from '../../../../hooks/useFetcher'; import { useUrlParams } from '../../../../hooks/useUrlParams'; -export const VisitorBreakdown = () => { +export function VisitorBreakdown() { const { urlParams, uiFilters } = useUrlParams(); const { start, end, serviceName } = urlParams; @@ -62,4 +62,4 @@ export const VisitorBreakdown = () => { ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx index b330129f83785..f314fbbb1fba0 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx @@ -4,32 +4,38 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FunctionComponent } from 'react'; import { act, wait } from '@testing-library/react'; import cytoscape from 'cytoscape'; -import { CytoscapeContext } from './Cytoscape'; -import { EmptyBanner } from './EmptyBanner'; +import React, { ReactNode } from 'react'; import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; import { renderWithTheme } from '../../../utils/testHelpers'; +import { CytoscapeContext } from './Cytoscape'; +import { EmptyBanner } from './EmptyBanner'; const cy = cytoscape({}); -const wrapper: FunctionComponent = ({ children }) => ( - - {children} - -); +function wrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} describe('EmptyBanner', () => { describe('when cy is undefined', () => { it('renders null', () => { - const noCytoscapeWrapper: FunctionComponent = ({ children }) => ( - - - {children} - - - ); + function noCytoscapeWrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); + } const component = renderWithTheme(, { wrapper: noCytoscapeWrapper, }); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx index 9e805058e8cb5..8557c3f0c0798 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx @@ -34,26 +34,28 @@ interface Props { percentageLoaded: number; } -export const LoadingOverlay = ({ isLoading, percentageLoaded }: Props) => ( - - {isLoading && ( - - - - - - - {i18n.translate('xpack.apm.loadingServiceMap', { - defaultMessage: - 'Loading service map... This might take a short while.', - })} - - - )} - -); +export function LoadingOverlay({ isLoading, percentageLoaded }: Props) { + return ( + + {isLoading && ( + + + + + + + {i18n.translate('xpack.apm.loadingServiceMap', { + defaultMessage: + 'Loading service map... This might take a short while.', + })} + + + )} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx index 78466b2659bb7..4911d7f147d7c 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx @@ -34,20 +34,21 @@ interface ContentsProps { // @ts-ignore `documentMode` is not recognized as a valid property of `document`. const isIE11 = !!window.MSInputMethodContext && !!document.documentMode; -const FlexColumnGroup = (props: { +function FlexColumnGroup(props: { children: React.ReactNode; style: React.CSSProperties; direction: 'column'; gutterSize: 's'; -}) => { +}) { if (isIE11) { const { direction, gutterSize, ...rest } = props; return
; } return ; -}; -const FlexColumnItem = (props: { children: React.ReactNode }) => - isIE11 ?
: ; +} +function FlexColumnItem(props: { children: React.ReactNode }) { + return isIE11 ?
: ; +} export function Contents({ selectedNodeData, diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/index.test.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/index.test.tsx index f36b94f2971cd..4a56f75b05de9 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/index.test.tsx @@ -5,7 +5,7 @@ */ import { render } from '@testing-library/react'; -import React, { FunctionComponent } from 'react'; +import React, { ReactNode } from 'react'; import { License } from '../../../../../licensing/common/license'; import { LicenseContext } from '../../../context/LicenseContext'; import { ServiceMap } from './'; @@ -22,13 +22,13 @@ const expiredLicense = new License({ }, }); -const Wrapper: FunctionComponent = ({ children }) => { +function Wrapper({ children }: { children?: ReactNode }) { return ( {children} ); -}; +} describe('ServiceMap', () => { describe('with an inactive license', () => { diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx index 7f3d25efa6f44..d4be4da2ae1c5 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -29,7 +29,7 @@ interface ServiceMapProps { serviceName?: string; } -export const ServiceMap = ({ serviceName }: ServiceMapProps) => { +export function ServiceMap({ serviceName }: ServiceMapProps) { const theme = useTheme(); const license = useLicense(); const { urlParams } = useUrlParams(); @@ -101,4 +101,4 @@ export const ServiceMap = ({ serviceName }: ServiceMapProps) => { ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx index 62ea3bc42860a..5537a73d228e8 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx @@ -36,7 +36,7 @@ const ServiceNodeName = styled.div` ${truncate(px(8 * unit))} `; -const ServiceNodeOverview = () => { +function ServiceNodeOverview() { const { uiFilters, urlParams } = useUrlParams(); const { serviceName, start, end } = urlParams; @@ -182,6 +182,6 @@ const ServiceNodeOverview = () => { ); -}; +} export { ServiceNodeOverview }; diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx index 0f23e230733b4..ce325a57426f5 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx @@ -38,7 +38,7 @@ interface Props { refetch: () => void; } -export const AgentConfigurationList = ({ status, data, refetch }: Props) => { +export function AgentConfigurationList({ status, data, refetch }: Props) { const theme = useTheme(); const [configToBeDeleted, setConfigToBeDeleted] = useState( null @@ -219,4 +219,4 @@ export const AgentConfigurationList = ({ status, data, refetch }: Props) => { /> ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx index 919cc4debe4d8..2e860ebe22c0f 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx @@ -7,15 +7,13 @@ import React from 'react'; import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -export const CreateCustomLinkButton = ({ - onClick, -}: { - onClick: () => void; -}) => ( - - {i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.createCustomLink', - { defaultMessage: 'Create custom link' } - )} - -); +export function CreateCustomLinkButton({ onClick }: { onClick: () => void }) { + return ( + + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.createCustomLink', + { defaultMessage: 'Create custom link' } + )} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/Documentation.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/Documentation.tsx index 48a0288f11ae5..262d22be25272 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/Documentation.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/Documentation.tsx @@ -9,8 +9,14 @@ import { ElasticDocsLink } from '../../../../../shared/Links/ElasticDocsLink'; interface Props { label: string; } -export const Documentation = ({ label }: Props) => ( - - {label} - -); +export function Documentation({ label }: Props) { + return ( + + {label} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx index daadc1bace9c4..8cf0f03175fc2 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx @@ -26,13 +26,13 @@ import { getSelectOptions, } from './helper'; -export const FiltersSection = ({ +export function FiltersSection({ filters, onChangeFilters, }: { filters: Filter[]; onChangeFilters: (filters: Filter[]) => void; -}) => { +}) { const onChangeFilter = ( key: Filter['key'], value: Filter['value'], @@ -147,25 +147,27 @@ export const FiltersSection = ({ /> ); -}; +} -const AddFilterButton = ({ +function AddFilterButton({ onClick, isDisabled, }: { onClick: () => void; isDisabled: boolean; -}) => ( - - {i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.flyout.filters.addAnotherFilter', - { - defaultMessage: 'Add another filter', - } - )} - -); +}) { + return ( + + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.filters.addAnotherFilter', + { + defaultMessage: 'Add another filter', + } + )} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx index 4fde75602990c..17c3fb265bca5 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx @@ -14,7 +14,7 @@ import { import { i18n } from '@kbn/i18n'; import { DeleteButton } from './DeleteButton'; -export const FlyoutFooter = ({ +export function FlyoutFooter({ onClose, isSaving, onDelete, @@ -26,7 +26,7 @@ export const FlyoutFooter = ({ onDelete: () => void; customLinkId?: string; isSaveButtonEnabled: boolean; -}) => { +}) { return ( @@ -61,4 +61,4 @@ export const FlyoutFooter = ({ ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx index b229157d1b1a8..b7250bda30966 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx @@ -41,7 +41,7 @@ const fetchTransaction = debounce( const getTextColor = (value?: string) => (value ? 'default' : 'subdued'); -export const LinkPreview = ({ label, url, filters }: Props) => { +export function LinkPreview({ label, url, filters }: Props) { const [transaction, setTransaction] = useState(); useEffect(() => { @@ -128,4 +128,4 @@ export const LinkPreview = ({ label, url, filters }: Props) => { ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx index 6a31752d11705..49307cbb8efba 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx @@ -31,12 +31,7 @@ interface Props { onChangeUrl: (url: string) => void; } -export const LinkSection = ({ - label, - onChangeLabel, - url, - onChangeUrl, -}: Props) => { +export function LinkSection({ label, onChangeLabel, url, onChangeUrl }: Props) { const inputFields: InputField[] = [ { name: 'label', @@ -145,4 +140,4 @@ export const LinkSection = ({ })} ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx index ccd98bd005666..9687846d6c520 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx @@ -37,13 +37,13 @@ interface Props { const filtersEmptyState: Filter[] = [{ key: '', value: '' }]; -export const CustomLinkFlyout = ({ +export function CustomLinkFlyout({ onClose, onSave, onDelete, defaults, customLinkId, -}: Props) => { +}: Props) { const { toasts } = useApmPluginContext().core.notifications; const [isSaving, setIsSaving] = useState(false); @@ -139,4 +139,4 @@ export const CustomLinkFlyout = ({ ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx index f2aabc878bf2d..d512ea19c7892 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx @@ -24,10 +24,7 @@ interface Props { onCustomLinkSelected: (customLink: CustomLink) => void; } -export const CustomLinkTable = ({ - items = [], - onCustomLinkSelected, -}: Props) => { +export function CustomLinkTable({ items = [], onCustomLinkSelected }: Props) { const [searchTerm, setSearchTerm] = useState(''); const columns = [ @@ -121,20 +118,22 @@ export const CustomLinkTable = ({ /> ); -}; +} -const NoResultFound = ({ value }: { value: string }) => ( - - - - {i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.table.noResultFound', - { - defaultMessage: `No results for "{value}".`, - values: { value }, - } - )} - - - -); +function NoResultFound({ value }: { value: string }) { + return ( + + + + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.noResultFound', + { + defaultMessage: `No results for "{value}".`, + values: { value }, + } + )} + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx index ee9350e320e1a..9411043c0b716 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx @@ -8,11 +8,11 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { CreateCustomLinkButton } from './CreateCustomLinkButton'; -export const EmptyPrompt = ({ +export function EmptyPrompt({ onCreateCustomLinkClick, }: { onCreateCustomLinkClick: () => void; -}) => { +}) { return ( } /> ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx index 95b8adb403981..22d8749d78834 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx @@ -7,34 +7,36 @@ import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -export const Title = () => ( - - - - - -

- {i18n.translate('xpack.apm.settings.customizeUI.customLink', { - defaultMessage: 'Custom Links', - })} -

-
+export function Title() { + return ( + + + + + +

+ {i18n.translate('xpack.apm.settings.customizeUI.customLink', { + defaultMessage: 'Custom Links', + })} +

+
- - - -
-
-
-
-); + + + +
+
+
+
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx index b4acc783d08ed..aa34515ea460a 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx @@ -18,7 +18,7 @@ import { Title } from './Title'; import { CreateCustomLinkButton } from './CreateCustomLinkButton'; import { LicensePrompt } from '../../../../shared/LicensePrompt'; -export const CustomLinkOverview = () => { +export function CustomLinkOverview() { const license = useLicense(); const hasValidLicense = license?.isActive && license?.hasAtLeast('gold'); @@ -107,4 +107,4 @@ export const CustomLinkOverview = () => { ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx index c88eba1c87b57..84408a7624403 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx @@ -9,7 +9,7 @@ import { EuiTitle, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { CustomLinkOverview } from './CustomLink'; -export const CustomizeUI = () => { +export function CustomizeUI() { return ( <> @@ -23,4 +23,4 @@ export const CustomizeUI = () => { ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx index c9328c4988e5f..cb2090d1cbe2b 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx @@ -31,11 +31,11 @@ interface Props { onCreateJobSuccess: () => void; onCancel: () => void; } -export const AddEnvironments = ({ +export function AddEnvironments({ currentEnvironments, onCreateJobSuccess, onCancel, -}: Props) => { +}: Props) { const { notifications, application } = useApmPluginContext().core; const canCreateJob = !!application.capabilities.ml.canCreateJob; const { toasts } = notifications; @@ -175,4 +175,4 @@ export const AddEnvironments = ({ ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx index abbe1e2c83c7b..dab30761c6ebe 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx @@ -28,7 +28,7 @@ const DEFAULT_VALUE: AnomalyDetectionApiResponse = { errorCode: undefined, }; -export const AnomalyDetection = () => { +export function AnomalyDetection() { const plugin = useApmPluginContext(); const canGetJobs = !!plugin.core.application.capabilities.ml.canGetJobs; const license = useLicense(); @@ -112,4 +112,4 @@ export const AnomalyDetection = () => { )} ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx index f3b8822010f59..8494004ae5639 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx @@ -65,7 +65,7 @@ interface Props { status: FETCH_STATUS; onAddEnvironments: () => void; } -export const JobsList = ({ data, status, onAddEnvironments }: Props) => { +export function JobsList({ data, status, onAddEnvironments }: Props) { const { jobs, hasLegacyJobs, errorCode } = data; return ( @@ -127,7 +127,7 @@ export const JobsList = ({ data, status, onAddEnvironments }: Props) => { {hasLegacyJobs && } ); -}; +} function getNoItemsMessage({ status, diff --git a/x-pack/plugins/apm/public/components/app/Settings/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/index.tsx index 6d8571bf57767..bd2ea706e492d 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty, @@ -17,7 +17,7 @@ import { HomeLink } from '../../shared/Links/apm/HomeLink'; import { useLocation } from '../../../hooks/useLocation'; import { getAPMHref } from '../../shared/Links/apm/APMLink'; -export const Settings: React.FC = (props) => { +export function Settings(props: { children: ReactNode }) { const { search, pathname } = useLocation(); return ( <> @@ -84,4 +84,4 @@ export const Settings: React.FC = (props) => { ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/TraceLink/index.tsx b/x-pack/plugins/apm/public/components/app/TraceLink/index.tsx index 3eb5a855ee3b4..55ab275002b4e 100644 --- a/x-pack/plugins/apm/public/components/app/TraceLink/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TraceLink/index.tsx @@ -58,7 +58,7 @@ const redirectToTracePage = ({ }, }); -export const TraceLink = () => { +export function TraceLink() { const { urlParams } = useUrlParams(); const { traceIdLink: traceId, rangeFrom, rangeTo } = urlParams; @@ -93,4 +93,4 @@ export const TraceLink = () => { Fetching trace...} /> ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx index 1244dd01a3b43..90bbe0a5a2135 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx @@ -7,19 +7,19 @@ import { EuiIconTip, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import d3 from 'd3'; -import React, { FunctionComponent, useCallback } from 'react'; import { isEmpty } from 'lodash'; +import React, { useCallback } from 'react'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { TransactionDistributionAPIResponse } from '../../../../../server/lib/transactions/distribution'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { IBucket } from '../../../../../server/lib/transactions/distribution/get_buckets/transform'; import { IUrlParams } from '../../../../context/UrlParamsContext/types'; import { getDurationFormatter } from '../../../../utils/formatters'; +import { history } from '../../../../utils/history'; // @ts-ignore import Histogram from '../../../shared/charts/Histogram'; import { EmptyMessage } from '../../../shared/EmptyMessage'; import { fromQuery, toQuery } from '../../../shared/Links/url_helpers'; -import { history } from '../../../../utils/history'; import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; interface IChartPoint { @@ -99,9 +99,7 @@ interface Props { bucketIndex: number; } -export const TransactionDistribution: FunctionComponent = ( - props: Props -) => { +export function TransactionDistribution(props: Props) { const { distribution, urlParams: { transactionType }, @@ -211,4 +209,4 @@ export const TransactionDistribution: FunctionComponent = ( />
); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/ErrorCount.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/ErrorCount.tsx index 89757b227f8fd..20f93bce29ca8 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/ErrorCount.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/ErrorCount.tsx @@ -12,21 +12,23 @@ interface Props { count: number; } -export const ErrorCount = ({ count }: Props) => ( - -

- { - e.stopPropagation(); - }} - > - {i18n.translate('xpack.apm.transactionDetails.errorCount', { - defaultMessage: - '{errorCount, number} {errorCount, plural, one {Error} other {Errors}}', - values: { errorCount: count }, - })} - -

-
-); +export function ErrorCount({ count }: Props) { + return ( + +

+ { + e.stopPropagation(); + }} + > + {i18n.translate('xpack.apm.transactionDetails.errorCount', { + defaultMessage: + '{errorCount, number} {errorCount, plural, one {Error} other {Errors}}', + values: { errorCount: count }, + })} + +

+
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/TruncateHeightSection.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/TruncateHeightSection.tsx index 64e20cf10d8aa..4f32df2b3115e 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/TruncateHeightSection.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/TruncateHeightSection.tsx @@ -6,7 +6,7 @@ import { EuiIcon, EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { Fragment, useEffect, useRef, useState } from 'react'; +import React, { Fragment, ReactNode, useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; import { px, units } from '../../../../../../../style/variables'; @@ -16,13 +16,11 @@ const ToggleButtonContainer = styled.div` `; interface Props { + children: ReactNode; previewHeight: number; } -export const TruncateHeightSection: React.FC = ({ - children, - previewHeight, -}) => { +export function TruncateHeightSection({ children, previewHeight }: Props) { const contentContainerEl = useRef(null); const [showToggle, setShowToggle] = useState(true); @@ -73,4 +71,4 @@ export const TruncateHeightSection: React.FC = ({ ) : null} ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallFlyout.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallFlyout.tsx index f0150e5a1b758..7e1dbddf56025 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallFlyout.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallFlyout.tsx @@ -15,12 +15,13 @@ interface Props { location: Location; toggleFlyout: ({ location }: { location: Location }) => void; } -export const WaterfallFlyout: React.FC = ({ + +export function WaterfallFlyout({ waterfallItemId, waterfall, location, toggleFlyout, -}) => { +}: Props) { const currentItem = waterfall.items.find( (item) => item.id === waterfallItemId ); @@ -58,4 +59,4 @@ export const WaterfallFlyout: React.FC = ({ default: return null; } -}; +} diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx index a25ae71947f21..a4d42bcf51d01 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import styled from 'styled-components'; import { EuiIcon, EuiText, EuiTitle, EuiToolTip } from '@elastic/eui'; @@ -109,13 +109,11 @@ function PrefixIcon({ item }: { item: IWaterfallItem }) { } interface SpanActionToolTipProps { + children: ReactNode; item?: IWaterfallItem; } -const SpanActionToolTip: React.FC = ({ - item, - children, -}) => { +function SpanActionToolTip({ item, children }: SpanActionToolTipProps) { if (item?.docType === 'span') { return ( @@ -124,7 +122,7 @@ const SpanActionToolTip: React.FC = ({ ); } return <>{children}; -}; +} function Duration({ item }: { item: IWaterfallItem }) { return ( diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx index 78235594f40ec..1fd0ec761b1ae 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx @@ -67,12 +67,12 @@ interface Props { exceedsMax: boolean; } -export const Waterfall: React.FC = ({ +export function Waterfall({ waterfall, exceedsMax, waterfallItemId, location, -}) => { +}: Props) { const itemContainerHeight = 58; // TODO: This is a nasty way to calculate the height of the svg element. A better approach should be found const waterfallHeight = itemContainerHeight * waterfall.items.length; @@ -81,7 +81,7 @@ export const Waterfall: React.FC = ({ const agentMarks = getAgentMarks(waterfall.entryTransaction); const errorMarks = getErrorMarks(waterfall.errorItems, serviceColors); - const renderWaterfallItem = (item: IWaterfallItem) => { + function renderWaterfallItem(item: IWaterfallItem) { const errorCount = item.docType === 'transaction' ? waterfall.errorsPerTransaction[item.doc.transaction.id] @@ -99,7 +99,7 @@ export const Waterfall: React.FC = ({ onClick={() => toggleFlyout({ item, location })} /> ); - }; + } return ( @@ -134,4 +134,4 @@ export const Waterfall: React.FC = ({ /> ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx index beb0c03f37f8f..12676b7c15f1c 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx @@ -37,14 +37,14 @@ interface Props { traceSamples: IBucket['samples']; } -export const WaterfallWithSummmary: React.FC = ({ +export function WaterfallWithSummmary({ urlParams, location, waterfall, exceedsMax, isLoading, traceSamples, -}) => { +}: Props) { const [sampleActivePage, setSampleActivePage] = useState(0); useEffect(() => { @@ -135,4 +135,4 @@ export const WaterfallWithSummmary: React.FC = ({ /> ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx index cccbdc8d86d91..4ffd422801816 100644 --- a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx @@ -5,29 +5,31 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import React from 'react'; +import React, { ReactNode } from 'react'; import { KueryBar } from '../KueryBar'; import { DatePicker } from '../DatePicker'; import { EnvironmentFilter } from '../EnvironmentFilter'; -export const ApmHeader: React.FC = ({ children }) => ( - <> - - {children} - - - - +export function ApmHeader({ children }: { children: ReactNode }) { + return ( + <> + + {children} + + + + - + - - - - - - - - - -); + + + + + + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx index 215e97aebf646..36e33fba89fbb 100644 --- a/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { LocationProvider } from '../../../../context/LocationContext'; import { UrlParamsContext, @@ -21,18 +21,24 @@ import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContex const mockHistoryPush = jest.spyOn(history, 'push'); const mockRefreshTimeRange = jest.fn(); -const MockUrlParamsProvider: React.FC<{ +function MockUrlParamsProvider({ + params = {}, + children, +}: { + children: ReactNode; params?: IUrlParams; -}> = ({ params = {}, children }) => ( - -); +}) { + return ( + + ); +} function mountDatePicker(params?: IUrlParams) { return mount( diff --git a/x-pack/plugins/apm/public/components/shared/EmptyMessage.tsx b/x-pack/plugins/apm/public/components/shared/EmptyMessage.tsx index f300ed9d65aac..296df901d309e 100644 --- a/x-pack/plugins/apm/public/components/shared/EmptyMessage.tsx +++ b/x-pack/plugins/apm/public/components/shared/EmptyMessage.tsx @@ -14,7 +14,7 @@ interface Props { hideSubheading?: boolean; } -const EmptyMessage: React.FC = ({ +function EmptyMessage({ heading = i18n.translate('xpack.apm.emptyMessage.noDataFoundLabel', { defaultMessage: 'No data found.', }), @@ -22,7 +22,7 @@ const EmptyMessage: React.FC = ({ defaultMessage: 'Try another time range or reset the search filter.', }), hideSubheading = false, -}) => { +}: Props) { return ( = ({ body={!hideSubheading && subheading} /> ); -}; +} export { EmptyMessage }; diff --git a/x-pack/plugins/apm/public/components/shared/EnvironmentBadge/index.tsx b/x-pack/plugins/apm/public/components/shared/EnvironmentBadge/index.tsx index 47e52285b6851..a430eea1cf40c 100644 --- a/x-pack/plugins/apm/public/components/shared/EnvironmentBadge/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/EnvironmentBadge/index.tsx @@ -11,7 +11,7 @@ import { EuiBadge, EuiToolTip } from '@elastic/eui'; interface Props { environments: string[]; } -export const EnvironmentBadge: React.FC = ({ environments = [] }) => { +export function EnvironmentBadge({ environments = [] }: Props) { if (environments.length < 3) { return ( <> @@ -42,4 +42,4 @@ export const EnvironmentBadge: React.FC = ({ environments = [] }) => { ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx b/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx index 28dd5e7a5a363..1490ca42679b9 100644 --- a/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx @@ -65,7 +65,7 @@ function getOptions(environments: string[]) { ]; } -export const EnvironmentFilter: React.FC = () => { +export function EnvironmentFilter() { const location = useLocation(); const { uiFilters, urlParams } = useUrlParams(); @@ -90,4 +90,4 @@ export const EnvironmentFilter: React.FC = () => { isLoading={status === 'loading'} /> ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/EuiTabLink.tsx b/x-pack/plugins/apm/public/components/shared/EuiTabLink.tsx index 8538ea6a510ce..d29ccd8abcd42 100644 --- a/x-pack/plugins/apm/public/components/shared/EuiTabLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/EuiTabLink.tsx @@ -32,7 +32,7 @@ const Wrapper = styled.div<{ isSelected: boolean }>` } `; -const EuiTabLink = (props: Props) => { +function EuiTabLink(props: Props) { const { isSelected, children } = props; const className = cls('euiTab', { @@ -44,6 +44,6 @@ const EuiTabLink = (props: Props) => { {children} ); -}; +} export { EuiTabLink }; diff --git a/x-pack/plugins/apm/public/components/shared/HeightRetainer/index.tsx b/x-pack/plugins/apm/public/components/shared/HeightRetainer/index.tsx index be8ff87617c80..5c8755f9f586f 100644 --- a/x-pack/plugins/apm/public/components/shared/HeightRetainer/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/HeightRetainer/index.tsx @@ -6,7 +6,12 @@ import React, { useEffect, useRef } from 'react'; -export const HeightRetainer: React.FC = (props) => { +export function HeightRetainer( + props: React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + > +) { const containerElement = useRef(null); const minHeight = useRef(0); @@ -26,4 +31,4 @@ export const HeightRetainer: React.FC = (props) => { style={{ minHeight: minHeight.current }} /> ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx b/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx index 6ddc4eecba7ed..502f5f0034b5f 100644 --- a/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx @@ -112,7 +112,6 @@ export function KueryBar() { setState({ ...state, suggestions, isLoadingSuggestions: false }); } catch (e) { - // eslint-disable-next-line no-console console.error('Error while fetching suggestions', e); } } diff --git a/x-pack/plugins/apm/public/components/shared/LicensePrompt/index.tsx b/x-pack/plugins/apm/public/components/shared/LicensePrompt/index.tsx index d8464fdfa8481..50be268d9ccd0 100644 --- a/x-pack/plugins/apm/public/components/shared/LicensePrompt/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/LicensePrompt/index.tsx @@ -14,7 +14,7 @@ interface Props { showBetaBadge?: boolean; } -export const LicensePrompt = ({ text, showBetaBadge = false }: Props) => { +export function LicensePrompt({ text, showBetaBadge = false }: Props) { const licensePageUrl = useKibanaUrl( '/app/kibana', '/management/stack/license_management/home' @@ -60,4 +60,4 @@ export const LicensePrompt = ({ text, showBetaBadge = false }: Props) => { ); return <>{showBetaBadge ? renderWithBetaBadge : renderLicenseBody}; -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverErrorLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverErrorLink.tsx index 5679e31a9898b..d83f10cf1975f 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverErrorLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverErrorLink.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { ERROR_GROUP_ID, SERVICE_NAME, @@ -32,13 +32,18 @@ function getDiscoverQuery(error: APMError, kuery?: string) { }; } -const DiscoverErrorLink: React.FC<{ +function DiscoverErrorLink({ + error, + kuery, + children, +}: { + children?: ReactNode; readonly error: APMError; readonly kuery?: string; -}> = ({ error, kuery, children }) => { +}) { return ( ); -}; +} export { DiscoverErrorLink }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverSpanLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverSpanLink.tsx index 5fce3e842d8da..d7751c43b5943 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverSpanLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverSpanLink.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { SPAN_ID } from '../../../../../common/elasticsearch_fieldnames'; import { Span } from '../../../../../typings/es_schemas/ui/span'; import { DiscoverLink } from './DiscoverLink'; @@ -22,8 +22,12 @@ function getDiscoverQuery(span: Span) { }; } -export const DiscoverSpanLink: React.FC<{ +export function DiscoverSpanLink({ + span, + children, +}: { readonly span: Span; -}> = ({ span, children }) => { + children?: ReactNode; +}) { return ; -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverTransactionLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverTransactionLink.tsx index e2500617155c1..223fabbdb0d6f 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverTransactionLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverTransactionLink.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { PROCESSOR_EVENT, TRACE_ID, @@ -32,10 +32,14 @@ export function getDiscoverQuery(transaction: Transaction) { }; } -export const DiscoverTransactionLink: React.FC<{ +export function DiscoverTransactionLink({ + transaction, + children, +}: { readonly transaction: Transaction; -}> = ({ transaction, children }) => { + children?: ReactNode; +}) { return ( ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx index f3c5b49287293..887ac2ff6bbb9 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx @@ -4,24 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { EuiLink } from '@elastic/eui'; import { useTimeSeriesExplorerHref } from './useTimeSeriesExplorerHref'; interface Props { + children?: ReactNode; jobId: string; external?: boolean; serviceName?: string; transactionType?: string; } -export const MLJobLink: React.FC = ({ +export function MLJobLink({ jobId, serviceName, transactionType, external, children, -}) => { +}: Props) { const href = useTimeSeriesExplorerHref({ jobId, serviceName, @@ -36,4 +37,4 @@ export const MLJobLink: React.FC = ({ target={external ? '_blank' : undefined} /> ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/ErrorDetailLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/ErrorDetailLink.tsx index c788da6a0d240..1ff32b17f3245 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/ErrorDetailLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/ErrorDetailLink.tsx @@ -11,13 +11,13 @@ interface Props extends APMLinkExtendProps { errorGroupId: string; } -const ErrorDetailLink = ({ serviceName, errorGroupId, ...rest }: Props) => { +function ErrorDetailLink({ serviceName, errorGroupId, ...rest }: Props) { return ( ); -}; +} export { ErrorDetailLink }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/ErrorOverviewLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/ErrorOverviewLink.tsx index 684531d50897c..862b1ac649648 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/ErrorOverviewLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/ErrorOverviewLink.tsx @@ -14,7 +14,7 @@ interface Props extends APMLinkExtendProps { query?: APMQueryParams; } -const ErrorOverviewLink = ({ serviceName, query, ...rest }: Props) => { +function ErrorOverviewLink({ serviceName, query, ...rest }: Props) { const { urlParams } = useUrlParams(); const persistedFilters = pickKeys( @@ -35,6 +35,6 @@ const ErrorOverviewLink = ({ serviceName, query, ...rest }: Props) => { {...rest} /> ); -}; +} export { ErrorOverviewLink }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/HomeLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/HomeLink.tsx index 92ff3164880e8..724b9536dfaa3 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/HomeLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/HomeLink.tsx @@ -6,8 +6,8 @@ import React from 'react'; import { APMLink, APMLinkExtendProps } from './APMLink'; -const HomeLink = (props: APMLinkExtendProps) => { +function HomeLink(props: APMLinkExtendProps) { return ; -}; +} export { HomeLink }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/MetricOverviewLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/MetricOverviewLink.tsx index bd3e3b36a8601..35ba5db68d507 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/MetricOverviewLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/MetricOverviewLink.tsx @@ -12,7 +12,7 @@ interface Props extends APMLinkExtendProps { serviceName: string; } -const MetricOverviewLink = ({ serviceName, ...rest }: Props) => { +function MetricOverviewLink({ serviceName, ...rest }: Props) { const { urlParams } = useUrlParams(); const persistedFilters = pickKeys( @@ -30,6 +30,6 @@ const MetricOverviewLink = ({ serviceName, ...rest }: Props) => { {...rest} /> ); -}; +} export { MetricOverviewLink }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceMapLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceMapLink.tsx index 36c108160bdb2..ff8b1354daeb5 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceMapLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceMapLink.tsx @@ -16,11 +16,11 @@ interface ServiceMapLinkProps extends APMLinkExtendProps { serviceName?: string; } -const ServiceMapLink = ({ serviceName, ...rest }: ServiceMapLinkProps) => { +function ServiceMapLink({ serviceName, ...rest }: ServiceMapLinkProps) { const path = serviceName ? `/services/${serviceName}/service-map` : '/service-map'; return ; -}; +} export { ServiceMapLink }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeMetricOverviewLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeMetricOverviewLink.tsx index 1473221cca2be..2553ec4353194 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeMetricOverviewLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeMetricOverviewLink.tsx @@ -13,11 +13,11 @@ interface Props extends APMLinkExtendProps { serviceNodeName: string; } -const ServiceNodeMetricOverviewLink = ({ +function ServiceNodeMetricOverviewLink({ serviceName, serviceNodeName, ...rest -}: Props) => { +}: Props) { const { urlParams } = useUrlParams(); const persistedFilters = pickKeys( @@ -37,6 +37,6 @@ const ServiceNodeMetricOverviewLink = ({ {...rest} /> ); -}; +} export { ServiceNodeMetricOverviewLink }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeOverviewLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeOverviewLink.tsx index b479ab77e1127..111c2391cd54f 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeOverviewLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeOverviewLink.tsx @@ -12,7 +12,7 @@ interface Props extends APMLinkExtendProps { serviceName: string; } -const ServiceNodeOverviewLink = ({ serviceName, ...rest }: Props) => { +function ServiceNodeOverviewLink({ serviceName, ...rest }: Props) { const { urlParams } = useUrlParams(); const persistedFilters = pickKeys( @@ -30,6 +30,6 @@ const ServiceNodeOverviewLink = ({ serviceName, ...rest }: Props) => { {...rest} /> ); -}; +} export { ServiceNodeOverviewLink }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceOverviewLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceOverviewLink.tsx index 577209a26e46b..2081fc4767903 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceOverviewLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceOverviewLink.tsx @@ -14,12 +14,12 @@ import { APMLink, APMLinkExtendProps } from './APMLink'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { pickKeys } from '../../../../../common/utils/pick_keys'; -const ServiceOverviewLink = (props: APMLinkExtendProps) => { +function ServiceOverviewLink(props: APMLinkExtendProps) { const { urlParams } = useUrlParams(); const persistedFilters = pickKeys(urlParams, 'host', 'agentName'); return ; -}; +} export { ServiceOverviewLink }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/SettingsLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/SettingsLink.tsx index 853972f4df402..80f3053b86f93 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/SettingsLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/SettingsLink.tsx @@ -6,8 +6,8 @@ import React from 'react'; import { APMLink, APMLinkExtendProps } from './APMLink'; -const SettingsLink = (props: APMLinkExtendProps) => { +function SettingsLink(props: APMLinkExtendProps) { return ; -}; +} export { SettingsLink }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/TraceOverviewLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/TraceOverviewLink.tsx index dc4519365cbc2..8f3ea191fab1a 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/TraceOverviewLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/TraceOverviewLink.tsx @@ -14,7 +14,7 @@ import { APMLink, APMLinkExtendProps } from './APMLink'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { pickKeys } from '../../../../../common/utils/pick_keys'; -const TraceOverviewLink = (props: APMLinkExtendProps) => { +function TraceOverviewLink(props: APMLinkExtendProps) { const { urlParams } = useUrlParams(); const persistedFilters = pickKeys( @@ -26,6 +26,6 @@ const TraceOverviewLink = (props: APMLinkExtendProps) => { ); return ; -}; +} export { TraceOverviewLink }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionDetailLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionDetailLink.tsx index c7eba1984472e..2ca3dce5da9ce 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionDetailLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionDetailLink.tsx @@ -17,14 +17,14 @@ interface Props extends APMLinkExtendProps { transactionType: string; } -export const TransactionDetailLink = ({ +export function TransactionDetailLink({ serviceName, traceId, transactionId, transactionName, transactionType, ...rest -}: Props) => { +}: Props) { const { urlParams } = useUrlParams(); const persistedFilters = pickKeys( @@ -46,4 +46,4 @@ export const TransactionDetailLink = ({ {...rest} /> ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx index ccef83ee73fb8..adc64f5a2d3dc 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx @@ -12,7 +12,7 @@ interface Props extends APMLinkExtendProps { serviceName: string; } -const TransactionOverviewLink = ({ serviceName, ...rest }: Props) => { +function TransactionOverviewLink({ serviceName, ...rest }: Props) { const { urlParams } = useUrlParams(); const persistedFilters = pickKeys( @@ -31,6 +31,6 @@ const TransactionOverviewLink = ({ serviceName, ...rest }: Props) => { {...rest} /> ); -}; +} export { TransactionOverviewLink }; diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterBadgeList.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterBadgeList.tsx index 9191f4e797637..2090a92bf0de4 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterBadgeList.tsx +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterBadgeList.tsx @@ -20,24 +20,26 @@ interface Props { onRemove: (val: string) => void; } -const FilterBadgeList = ({ onRemove, value }: Props) => ( - - {value.map((val) => ( - - - - ))} - -); +function FilterBadgeList({ onRemove, value }: Props) { + return ( + + {value.map((val) => ( + + + + ))} + + ); +} export { FilterBadgeList }; diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterTitleButton.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterTitleButton.tsx index 26125ab0f5343..0d306f5133716 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterTitleButton.tsx +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterTitleButton.tsx @@ -24,7 +24,7 @@ const Button = styled(EuiButtonEmpty).attrs(() => ({ type Props = React.ComponentProps; -export const FilterTitleButton = (props: Props) => { +export function FilterTitleButton(props: Props) { return ( ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx index 167574f9aa00d..c13439a3c5928 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx @@ -66,14 +66,7 @@ interface Props { type Option = EuiSelectable['props']['options'][0]; -const Filter = ({ - name, - title, - options, - onChange, - value, - showCount, -}: Props) => { +function Filter({ name, title, options, onChange, value, showCount }: Props) { const [showPopover, setShowPopover] = useState(false); const toggleShowPopover = () => setShowPopover((show) => !show); @@ -176,6 +169,6 @@ const Filter = ({ ) : null} ); -}; +} export { Filter }; diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx index 405a4cacae714..99656b05db450 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx @@ -21,7 +21,7 @@ interface Props { loading: boolean; } -const ServiceNameFilter = ({ loading, serviceNames }: Props) => { +function ServiceNameFilter({ loading, serviceNames }: Props) { const { urlParams: { serviceName }, } = useUrlParams(); @@ -72,6 +72,6 @@ const ServiceNameFilter = ({ loading, serviceNames }: Props) => { /> ); -}; +} export { ServiceNameFilter }; diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/TransactionTypeFilter/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/TransactionTypeFilter/index.tsx index 0e6b1c5904fc5..afd2d023d16ba 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/TransactionTypeFilter/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/TransactionTypeFilter/index.tsx @@ -20,7 +20,7 @@ interface Props { transactionTypes: string[]; } -const TransactionTypeFilter = ({ transactionTypes }: Props) => { +function TransactionTypeFilter({ transactionTypes }: Props) { const { urlParams: { transactionType }, } = useUrlParams(); @@ -59,6 +59,6 @@ const TransactionTypeFilter = ({ transactionTypes }: Props) => { /> ); -}; +} export { TransactionTypeFilter }; diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx index 020b7481c68ea..fedf96b4cc4ea 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx @@ -31,13 +31,13 @@ const ButtonWrapper = styled.div` display: inline-block; `; -const LocalUIFilters = ({ +function LocalUIFilters({ projection, params, filterNames, children, showCount = true, -}: Props) => { +}: Props) { const { filters, setFilterValue, clearValues } = useLocalUIFilters({ filterNames, projection, @@ -91,6 +91,6 @@ const LocalUIFilters = ({ ) : null} ); -}; +} export { LocalUIFilters }; diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx index eace3035a3555..8dfb1e0ce960d 100644 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx @@ -91,18 +91,20 @@ export function MetadataTable({ sections }: Props) { ); } -const NoResultFound = ({ value }: { value: string }) => ( - - - - {i18n.translate( - 'xpack.apm.propertiesTable.agentFeature.noResultFound', - { - defaultMessage: `No results for "{value}".`, - values: { value }, - } - )} - - - -); +function NoResultFound({ value }: { value: string }) { + return ( + + + + {i18n.translate( + 'xpack.apm.propertiesTable.agentFeature.noResultFound', + { + defaultMessage: `No results for "{value}".`, + values: { value }, + } + )} + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx b/x-pack/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx index e0da91fae2ba7..02939b18401fe 100644 --- a/x-pack/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx @@ -19,6 +19,7 @@ const DEFAULT_PLACEHOLDER = i18n.translate('xpack.apm.selectPlaceholder', { * with `hasNoInitialSelection`. It uses the `placeholder` prop to populate * the first option as the initial, not selected option. */ +// eslint-disable-next-line react/function-component-definition export const SelectWithPlaceholder: typeof EuiSelect = (props) => { const placeholder = props.placeholder || DEFAULT_PLACEHOLDER; return ( diff --git a/x-pack/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx b/x-pack/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx index 1abdb94c8313e..b07672eeaee06 100644 --- a/x-pack/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx @@ -13,7 +13,7 @@ interface Props { children?: React.ReactNode; } -export const PopoverExpression = (props: Props) => { +export function PopoverExpression(props: Props) { const { title, value, children } = props; const [popoverOpen, setPopoverOpen] = useState(false); @@ -36,4 +36,4 @@ export const PopoverExpression = (props: Props) => { {children} ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx index 48580146c6fe1..5891895629318 100644 --- a/x-pack/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx @@ -29,7 +29,7 @@ interface Props { isLibraryFrame: boolean; } -const FrameHeading: React.FC = ({ stackframe, isLibraryFrame }) => { +function FrameHeading({ stackframe, isLibraryFrame }: Props) { const FileDetail = isLibraryFrame ? LibraryFrameFileDetail : AppFrameFileDetail; @@ -50,6 +50,6 @@ const FrameHeading: React.FC = ({ stackframe, isLibraryFrame }) => { )} ); -}; +} export { FrameHeading }; diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/Variables.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/Variables.tsx index 4bd6d361d6714..07b5ed6868df5 100644 --- a/x-pack/plugins/apm/public/components/shared/Stacktrace/Variables.tsx +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/Variables.tsx @@ -23,7 +23,7 @@ interface Props { vars: IStackframe['vars']; } -export const Variables = ({ vars }: Props) => { +export function Variables({ vars }: Props) { if (!vars) { return null; } @@ -46,4 +46,4 @@ export const Variables = ({ vars }: Props) => { ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/Summary/DurationSummaryItem.tsx b/x-pack/plugins/apm/public/components/shared/Summary/DurationSummaryItem.tsx index 831f72e3925af..7858bebead408 100644 --- a/x-pack/plugins/apm/public/components/shared/Summary/DurationSummaryItem.tsx +++ b/x-pack/plugins/apm/public/components/shared/Summary/DurationSummaryItem.tsx @@ -15,11 +15,7 @@ interface Props { parentType: 'trace' | 'transaction'; } -const DurationSummaryItem = ({ - duration, - totalDuration, - parentType, -}: Props) => { +function DurationSummaryItem({ duration, totalDuration, parentType }: Props) { const calculatedTotalDuration = totalDuration === undefined ? duration : totalDuration; @@ -41,6 +37,6 @@ const DurationSummaryItem = ({ ); -}; +} export { DurationSummaryItem }; diff --git a/x-pack/plugins/apm/public/components/shared/Summary/ErrorCountSummaryItemBadge.tsx b/x-pack/plugins/apm/public/components/shared/Summary/ErrorCountSummaryItemBadge.tsx index b6ea6a714017d..ed33c59af36f4 100644 --- a/x-pack/plugins/apm/public/components/shared/Summary/ErrorCountSummaryItemBadge.tsx +++ b/x-pack/plugins/apm/public/components/shared/Summary/ErrorCountSummaryItemBadge.tsx @@ -19,7 +19,7 @@ const Badge = (styled(EuiBadge)` margin-top: ${px(units.eighth)}; ` as unknown) as typeof EuiBadge; -export const ErrorCountSummaryItemBadge = ({ count }: Props) => { +export function ErrorCountSummaryItemBadge({ count }: Props) { const theme = useTheme(); return ( @@ -31,4 +31,4 @@ export const ErrorCountSummaryItemBadge = ({ count }: Props) => { })} ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/Summary/TransactionSummary.tsx b/x-pack/plugins/apm/public/components/shared/Summary/TransactionSummary.tsx index 86b42844f1fa7..98543ffaa9218 100644 --- a/x-pack/plugins/apm/public/components/shared/Summary/TransactionSummary.tsx +++ b/x-pack/plugins/apm/public/components/shared/Summary/TransactionSummary.tsx @@ -20,7 +20,7 @@ interface Props { errorCount: number; } -const getTransactionResultSummaryItem = (transaction: Transaction) => { +function getTransactionResultSummaryItem(transaction: Transaction) { const result = transaction.transaction.result; const isRumAgent = isRumAgentName(transaction.agent.name); const url = isRumAgent @@ -39,13 +39,9 @@ const getTransactionResultSummaryItem = (transaction: Transaction) => { } return null; -}; +} -const TransactionSummary = ({ - transaction, - totalDuration, - errorCount, -}: Props) => { +function TransactionSummary({ transaction, totalDuration, errorCount }: Props) { const items = [ , ; -}; +} export { TransactionSummary }; diff --git a/x-pack/plugins/apm/public/components/shared/Summary/index.tsx b/x-pack/plugins/apm/public/components/shared/Summary/index.tsx index 55ac525d71192..aea62c88f5833 100644 --- a/x-pack/plugins/apm/public/components/shared/Summary/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/Summary/index.tsx @@ -26,7 +26,7 @@ const Item = styled(EuiFlexItem)` } `; -const Summary = ({ items }: Props) => { +function Summary({ items }: Props) { const filteredItems = items.filter(Boolean) as React.ReactElement[]; return ( @@ -38,6 +38,6 @@ const Summary = ({ items }: Props) => { ))} ); -}; +} export { Summary }; diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx index 00a839adc2fdd..27c6aa82ac674 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx @@ -24,7 +24,7 @@ const ScrollableContainer = styled.div` overflow: scroll; `; -export const CustomLinkPopover = ({ +export function CustomLinkPopover({ customLinks, onCreateCustomLinkClick, onClose, @@ -34,7 +34,7 @@ export const CustomLinkPopover = ({ onCreateCustomLinkClick: () => void; onClose: () => void; transaction: Transaction; -}) => { +}) { return ( <> @@ -71,4 +71,4 @@ export const CustomLinkPopover = ({ ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx index 40143b53f17c5..6b421bc370332 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx @@ -24,28 +24,30 @@ const TruncateText = styled(EuiText)` ${truncate(px(units.unit * 25))} `; -export const CustomLinkSection = ({ +export function CustomLinkSection({ customLinks, transaction, }: { customLinks: CustomLink[]; transaction: Transaction; -}) => ( -
    - {customLinks.map((link) => { - let href = link.url; - try { - href = Mustache.render(link.url, transaction); - } catch (e) { - // ignores any error that happens - } - return ( - - - {link.label} - - - ); - })} -
-); +}) { + return ( +
    + {customLinks.map((link) => { + let href = link.url; + try { + href = Mustache.render(link.url, transaction); + } catch (e) { + // ignores any error that happens + } + return ( + + + {link.label} + + + ); + })} +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx index 9740a9f1ee847..09cdaa26004bb 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx @@ -14,46 +14,48 @@ import { import { i18n } from '@kbn/i18n'; import { APMLink } from '../../Links/apm/APMLink'; -export const ManageCustomLink = ({ +export function ManageCustomLink({ onCreateCustomLinkClick, showCreateCustomLinkButton = true, }: { onCreateCustomLinkClick: () => void; showCreateCustomLinkButton?: boolean; -}) => ( - - - - - - - - - - - {showCreateCustomLinkButton && ( - - - {i18n.translate('xpack.apm.customLink.buttom.create.title', { - defaultMessage: 'Create', +}) { + return ( + + + + + + > + + + + - )} - - - -); + {showCreateCustomLinkButton && ( + + + {i18n.translate('xpack.apm.customLink.buttom.create.title', { + defaultMessage: 'Create', + })} + + + )} + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx index 40ac3c31d1d43..d6484f52e84f9 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx @@ -37,7 +37,7 @@ const SeeMoreButton = styled.button<{ show: boolean }>` } `; -export const CustomLink = ({ +export function CustomLink({ customLinks, status, onCreateCustomLinkClick, @@ -49,7 +49,7 @@ export const CustomLink = ({ onCreateCustomLinkClick: () => void; onSeeMoreClick: () => void; transaction: Transaction; -}) => { +}) { const renderEmptyPrompt = ( <> @@ -125,4 +125,4 @@ export const CustomLink = ({ )} ); -}; +} 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 2507eca9ff663..77d70c626183f 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx @@ -6,10 +6,8 @@ import { EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { FunctionComponent, useMemo, useState, MouseEvent } from 'react'; +import React, { MouseEvent, useMemo, useState } from 'react'; import url from 'url'; -import { Filter } from '../../../../common/custom_link/custom_link_types'; -import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; import { ActionMenu, ActionMenuDivider, @@ -19,32 +17,34 @@ import { SectionSubtitle, SectionTitle, } from '../../../../../observability/public'; +import { Filter } from '../../../../common/custom_link/custom_link_types'; +import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; import { useFetcher } from '../../../hooks/useFetcher'; +import { useLicense } from '../../../hooks/useLicense'; import { useLocation } from '../../../hooks/useLocation'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { CustomLinkFlyout } from '../../app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout'; +import { convertFiltersToQuery } from '../../app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper'; import { CustomLink } from './CustomLink'; import { CustomLinkPopover } from './CustomLink/CustomLinkPopover'; import { getSections } from './sections'; -import { useLicense } from '../../../hooks/useLicense'; -import { convertFiltersToQuery } from '../../app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper'; interface Props { readonly transaction: Transaction; } -const ActionMenuButton = ({ onClick }: { onClick: () => void }) => ( - - {i18n.translate('xpack.apm.transactionActionMenu.actionsButtonLabel', { - defaultMessage: 'Actions', - })} - -); - -export const TransactionActionMenu: FunctionComponent = ({ - transaction, -}: Props) => { +function ActionMenuButton({ onClick }: { onClick: () => void }) { + return ( + + {i18n.translate('xpack.apm.transactionActionMenu.actionsButtonLabel', { + defaultMessage: 'Actions', + })} + + ); +} + +export function TransactionActionMenu({ transaction }: Props) { const license = useLicense(); const hasValidLicense = license?.isActive && license?.hasAtLeast('gold'); @@ -211,4 +211,4 @@ export const TransactionActionMenu: FunctionComponent = ({ ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx index 2cb3696f88002..209657971620b 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx @@ -29,7 +29,7 @@ const formatTooltipValue = (coordinate: Coordinate) => { : NOT_AVAILABLE_LABEL; }; -const TransactionBreakdownGraph: React.FC = (props) => { +function TransactionBreakdownGraph(props: Props) { const { timeseries } = props; const trackApmEvent = useUiTracker({ app: 'apm' }); const handleHover = useMemo( @@ -49,6 +49,6 @@ const TransactionBreakdownGraph: React.FC = (props) => { onHover={handleHover} /> ); -}; +} export { TransactionBreakdownGraph }; diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownKpiList.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownKpiList.tsx index 3898679f83537..d3761cf0fe38e 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownKpiList.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownKpiList.tsx @@ -31,10 +31,7 @@ const Description = styled.span` } `; -const KpiDescription: React.FC<{ - name: string; - color: string; -}> = ({ name, color }) => { +function KpiDescription({ name, color }: { name: string; color: string }) { return ( ); -}; +} -const TransactionBreakdownKpiList: React.FC = ({ kpis }) => { +function TransactionBreakdownKpiList({ kpis }: Props) { return ( {kpis.map((kpi) => ( @@ -73,6 +70,6 @@ const TransactionBreakdownKpiList: React.FC = ({ kpis }) => { ))} ); -}; +} export { TransactionBreakdownKpiList }; diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx index 51cad6bc65a85..80ed9163ec08d 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx @@ -21,7 +21,7 @@ const emptyMessage = i18n.translate('xpack.apm.transactionBreakdown.noData', { defaultMessage: 'No data within this time range.', }); -const TransactionBreakdown = () => { +function TransactionBreakdown() { const { data, status } = useTransactionBreakdown(); const { kpis, timeseries } = data; const noHits = data.kpis.length === 0 && status === FETCH_STATUS.SUCCESS; @@ -51,6 +51,6 @@ const TransactionBreakdown = () => { ); -}; +} export { TransactionBreakdown }; diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx index ed57692d70a65..d02c5a5d08927 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx @@ -26,7 +26,7 @@ interface Props { overlay: Maybe; } -export const AnnotationsPlot = ({ plotValues, annotations }: Props) => { +export function AnnotationsPlot({ plotValues, annotations }: Props) { const theme = useTheme(); const tickValues = annotations.map((annotation) => annotation['@timestamp']); @@ -70,4 +70,4 @@ export const AnnotationsPlot = ({ plotValues, annotations }: Props) => { ))} ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx index f87be32b43fc1..a433b0b507239 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx @@ -21,7 +21,7 @@ const tickFormatY = (y?: number) => { return asPercent(y || 0, 1); }; -export const ErroneousTransactionsRateChart = () => { +export function ErroneousTransactionsRateChart() { const { urlParams, uiFilters } = useUrlParams(); const syncedChartsProps = useChartsSync(); @@ -105,4 +105,4 @@ export const ErroneousTransactionsRateChart = () => { /> ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/Legend/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/Legend/index.tsx index a00c46bcf324d..1a2a90c9fb3c3 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Legend/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Legend/index.tsx @@ -60,7 +60,7 @@ interface Props { indicator?: () => React.ReactNode; } -export const Legend: React.FC = ({ +export function Legend({ onClick, text, color, @@ -71,7 +71,7 @@ export const Legend: React.FC = ({ shape = Shape.circle, indicator, ...rest -}) => { +}: Props) { const theme = useTheme(); const indicatorColor = color || theme.eui.euiColorVis1; @@ -96,4 +96,4 @@ export const Legend: React.FC = ({ {text} ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.tsx index d2dea39b83d82..64e0fe33c982f 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.tsx @@ -27,7 +27,7 @@ interface Props { mark: AgentMark; } -export const AgentMarker: React.FC = ({ mark }) => { +export function AgentMarker({ mark }: Props) { const theme = useTheme(); return ( @@ -46,4 +46,4 @@ export const AgentMarker: React.FC = ({ mark }) => { ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx index d8e056deb769a..4567bc3f0f0b7 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx @@ -53,7 +53,7 @@ function truncateMessage(errorMessage?: string) { } } -export const ErrorMarker: React.FC = ({ mark }) => { +export function ErrorMarker({ mark }: Props) { const theme = useTheme(); const { urlParams } = useUrlParams(); const [isPopoverOpen, showPopover] = useState(false); @@ -123,4 +123,4 @@ export const ErrorMarker: React.FC = ({ mark }) => { ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/index.tsx index 03124952c3f88..71a1639af6dcc 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/index.tsx @@ -22,7 +22,7 @@ const MarkerContainer = styled.div` bottom: 0; `; -export const Marker: React.FC = ({ mark, x }) => { +export function Marker({ mark, x }: Props) { const legendWidth = 11; return ( @@ -33,4 +33,4 @@ export const Marker: React.FC = ({ mark, x }) => { )} ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/TimelineAxis.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/TimelineAxis.tsx index a9c36634381d4..5cbfcc695e012 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/TimelineAxis.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/TimelineAxis.tsx @@ -42,11 +42,11 @@ interface TimelineAxisProps { topTraceDuration: number; } -export const TimelineAxis = ({ +export function TimelineAxis({ plotValues, marks = [], topTraceDuration, -}: TimelineAxisProps) => { +}: TimelineAxisProps) { const theme = useTheme(); const { margins, tickValues, width, xDomain, xMax, xScale } = plotValues; const tickFormatter = getDurationFormatter(xMax); @@ -107,4 +107,4 @@ export const TimelineAxis = ({ }} ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/VerticalLines.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/VerticalLines.tsx index 0753cb318d3a4..5ea2e4cfedf18 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/VerticalLines.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/VerticalLines.tsx @@ -16,11 +16,11 @@ interface VerticalLinesProps { topTraceDuration: number; } -export const VerticalLines = ({ +export function VerticalLines({ topTraceDuration, plotValues, marks = [], -}: VerticalLinesProps) => { +}: VerticalLinesProps) { const { width, height, margins, xDomain, tickValues } = plotValues; const markTimes = marks @@ -63,4 +63,4 @@ export const VerticalLines = ({
); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/ChoroplethToolTip.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/ChoroplethToolTip.tsx index 9d13b23904b36..69d4e8109dfbf 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/ChoroplethToolTip.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/ChoroplethToolTip.tsx @@ -9,11 +9,15 @@ import { i18n } from '@kbn/i18n'; import { asDuration, asInteger } from '../../../../../utils/formatters'; import { fontSizes } from '../../../../../style/variables'; -export const ChoroplethToolTip: React.FC<{ +export function ChoroplethToolTip({ + name, + value, + docCount, +}: { name: string; value: number; docCount: number; -}> = ({ name, value, docCount }) => { +}) { return (
{name}
@@ -41,4 +45,4 @@ export const ChoroplethToolTip: React.FC<{
); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/index.tsx index a9a9343dde6be..965cb2ae4f50a 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/index.tsx @@ -66,7 +66,7 @@ const getMin = (items: ChoroplethItem[]) => const getMax = (items: ChoroplethItem[]) => Math.max(...items.map((item) => item.value)); -export const ChoroplethMap: React.FC = (props) => { +export function ChoroplethMap(props: Props) { const theme = useTheme(); const { items } = props; const containerRef = useRef(null); @@ -267,4 +267,4 @@ export const ChoroplethMap: React.FC = (props) => { ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/DurationByCountryMap/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/DurationByCountryMap/index.tsx index 61030679f45fd..2dd3d058e98b8 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/DurationByCountryMap/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/DurationByCountryMap/index.tsx @@ -11,7 +11,7 @@ import { useAvgDurationByCountry } from '../../../../../hooks/useAvgDurationByCo import { ChoroplethMap } from '../ChoroplethMap'; -export const DurationByCountryMap: React.FC = () => { +export function DurationByCountryMap() { const { data } = useAvgDurationByCountry(); return ( @@ -30,4 +30,4 @@ export const DurationByCountryMap: React.FC = () => { ); -}; +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx index cee74c81325ba..eaad883d2f9f6 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx @@ -30,7 +30,7 @@ interface Props { onHover?: () => void; } -const TransactionLineChart: React.FC = (props: Props) => { +function TransactionLineChart(props: Props) { const { series, tickFormatY, @@ -68,6 +68,6 @@ const TransactionLineChart: React.FC = (props: Props) => { {...(stacked ? { stackBy: 'y' } : {})} /> ); -}; +} export { TransactionLineChart }; diff --git a/x-pack/plugins/apm/public/context/ChartsSyncContext.tsx b/x-pack/plugins/apm/public/context/ChartsSyncContext.tsx index c00fc95f1f4f2..f93b69a877057 100644 --- a/x-pack/plugins/apm/public/context/ChartsSyncContext.tsx +++ b/x-pack/plugins/apm/public/context/ChartsSyncContext.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useState } from 'react'; +import React, { ReactNode, useMemo, useState } from 'react'; import { toQuery, fromQuery } from '../components/shared/Links/url_helpers'; import { history } from '../utils/history'; import { useUrlParams } from '../hooks/useUrlParams'; @@ -17,7 +17,7 @@ const ChartsSyncContext = React.createContext<{ onSelectionEnd: (range: { start: number; end: number }) => void; } | null>(null); -const ChartsSyncContextProvider: React.FC = ({ children }) => { +function ChartsSyncContextProvider({ children }: { children: ReactNode }) { const [time, setTime] = useState(null); const { urlParams, uiFilters } = useUrlParams(); @@ -78,6 +78,6 @@ const ChartsSyncContextProvider: React.FC = ({ children }) => { }, [time, data.annotations]); return ; -}; +} export { ChartsSyncContext, ChartsSyncContextProvider }; diff --git a/x-pack/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx b/x-pack/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx index 4e4fbabf5571a..fd01e057ac3de 100644 --- a/x-pack/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx +++ b/x-pack/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx @@ -21,11 +21,11 @@ interface Props { refreshTimeRange?: (time: any) => void; } -export const MockUrlParamsContextProvider = ({ +export function MockUrlParamsContextProvider({ params, children, refreshTimeRange = () => undefined, -}: Props) => { +}: Props) { const urlParams = { ...defaultUrlParams, ...params }; return ( ); -}; +} diff --git a/x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts b/x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts index 8ec81616ccff8..71024edc9815c 100644 --- a/x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts +++ b/x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts @@ -7,7 +7,7 @@ import { flatten } from 'lodash'; import { TimeSeries } from '../../typings/timeseries'; -export const getRangeFromTimeSeries = (timeseries: TimeSeries[]) => { +export function getRangeFromTimeSeries(timeseries: TimeSeries[]) { const dataPoints = flatten(timeseries.map((series) => series.data)); if (dataPoints.length) { @@ -18,4 +18,4 @@ export const getRangeFromTimeSeries = (timeseries: TimeSeries[]) => { } return null; -}; +} diff --git a/x-pack/plugins/apm/public/utils/testHelpers.tsx b/x-pack/plugins/apm/public/utils/testHelpers.tsx index 8e7f987966783..418312743c324 100644 --- a/x-pack/plugins/apm/public/utils/testHelpers.tsx +++ b/x-pack/plugins/apm/public/utils/testHelpers.tsx @@ -197,9 +197,11 @@ export function mountWithTheme( tree: React.ReactElement, { darkMode = false } = {} ) { - const WrappingThemeProvider = (props: any) => ( - {props.children} - ); + function WrappingThemeProvider(props: any) { + return ( + {props.children} + ); + } return mount(tree, { wrappingComponent: WrappingThemeProvider, diff --git a/x-pack/plugins/observability/public/application/index.tsx b/x-pack/plugins/observability/public/application/index.tsx index d76c033a41756..b0134ed8b746b 100644 --- a/x-pack/plugins/observability/public/application/index.tsx +++ b/x-pack/plugins/observability/public/application/index.tsx @@ -29,7 +29,7 @@ function getTitleFromBreadCrumbs(breadcrumbs: Breadcrumbs) { .join(' | '); } -const App = () => { +function App() { return ( <> @@ -53,7 +53,7 @@ const App = () => { ); -}; +} export const renderApp = (core: CoreStart, { element }: AppMountParameters) => { const i18nCore = core.i18n; diff --git a/x-pack/plugins/observability/public/components/app/chart_container/index.tsx b/x-pack/plugins/observability/public/components/app/chart_container/index.tsx index 2a0c25773eae5..b68ddbd06c778 100644 --- a/x-pack/plugins/observability/public/components/app/chart_container/index.tsx +++ b/x-pack/plugins/observability/public/components/app/chart_container/index.tsx @@ -18,12 +18,12 @@ interface Props { const CHART_HEIGHT = 170; -export const ChartContainer = ({ +export function ChartContainer({ isInitialLoad, children, iconSize = 'xl', height = CHART_HEIGHT, -}: Props) => { +}: Props) { if (isInitialLoad) { return (
{children}; -}; +} diff --git a/x-pack/plugins/observability/public/components/app/empty_section/index.tsx b/x-pack/plugins/observability/public/components/app/empty_section/index.tsx index 4c830b2b2f094..5a2e64459358d 100644 --- a/x-pack/plugins/observability/public/components/app/empty_section/index.tsx +++ b/x-pack/plugins/observability/public/components/app/empty_section/index.tsx @@ -11,7 +11,7 @@ interface Props { section: ISection; } -export const EmptySection = ({ section }: Props) => { +export function EmptySection({ section }: Props) { return ( { } /> ); -}; +} diff --git a/x-pack/plugins/observability/public/components/app/header/index.tsx b/x-pack/plugins/observability/public/components/app/header/index.tsx index 531e6abf3d236..0e35fbb008bee 100644 --- a/x-pack/plugins/observability/public/components/app/header/index.tsx +++ b/x-pack/plugins/observability/public/components/app/header/index.tsx @@ -38,12 +38,12 @@ interface Props { showGiveFeedback?: boolean; } -export const Header = ({ +export function Header({ color, restrictWidth, showAddData = false, showGiveFeedback = false, -}: Props) => { +}: Props) { const { core } = usePluginContext(); return ( @@ -91,4 +91,4 @@ export const Header = ({ ); -}; +} diff --git a/x-pack/plugins/observability/public/components/app/ingest_manager_panel/index.tsx b/x-pack/plugins/observability/public/components/app/ingest_manager_panel/index.tsx index 41bcfa1da7fa1..1ab9f75632d9d 100644 --- a/x-pack/plugins/observability/public/components/app/ingest_manager_panel/index.tsx +++ b/x-pack/plugins/observability/public/components/app/ingest_manager_panel/index.tsx @@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n'; import { EuiText } from '@elastic/eui'; import { EuiLink } from '@elastic/eui'; -export const IngestManagerPanel = () => { +export function IngestManagerPanel() { return ( { ); -}; +} diff --git a/x-pack/plugins/observability/public/components/app/layout/with_header.tsx b/x-pack/plugins/observability/public/components/app/layout/with_header.tsx index 27b25f0056055..a77487e1244e6 100644 --- a/x-pack/plugins/observability/public/components/app/layout/with_header.tsx +++ b/x-pack/plugins/observability/public/components/app/layout/with_header.tsx @@ -32,23 +32,25 @@ interface Props { showGiveFeedback?: boolean; } -export const WithHeaderLayout = ({ +export function WithHeaderLayout({ headerColor, bodyColor, children, restrictWidth, showAddData, showGiveFeedback, -}: Props) => ( - -
- - {children} - - -); +}: Props) { + return ( + +
+ + {children} + + + ); +} diff --git a/x-pack/plugins/observability/public/components/app/news_feed/index.tsx b/x-pack/plugins/observability/public/components/app/news_feed/index.tsx index 2fbd6659bcb5a..625ae94c90aa2 100644 --- a/x-pack/plugins/observability/public/components/app/news_feed/index.tsx +++ b/x-pack/plugins/observability/public/components/app/news_feed/index.tsx @@ -23,7 +23,7 @@ interface Props { items: INewsItem[]; } -export const NewsFeed = ({ items }: Props) => { +export function NewsFeed({ items }: Props) { return ( // The news feed is manually added/edited, to prevent any errors caused by typos or missing fields, // wraps the component with EuiErrorBoundary to avoid breaking the entire page. @@ -46,11 +46,11 @@ export const NewsFeed = ({ items }: Props) => { ); -}; +} const limitString = (string: string, limit: number) => truncate(string, { length: limit }); -const NewsItem = ({ item }: { item: INewsItem }) => { +function NewsItem({ item }: { item: INewsItem }) { const theme = useContext(ThemeContext); return ( @@ -98,4 +98,4 @@ const NewsItem = ({ item }: { item: INewsItem }) => { ); -}; +} diff --git a/x-pack/plugins/observability/public/components/app/resources/index.tsx b/x-pack/plugins/observability/public/components/app/resources/index.tsx index 929802df3329b..47ac5f0f6d301 100644 --- a/x-pack/plugins/observability/public/components/app/resources/index.tsx +++ b/x-pack/plugins/observability/public/components/app/resources/index.tsx @@ -31,7 +31,7 @@ const resources = [ }, ]; -export const Resources = () => { +export function Resources() { return ( @@ -46,4 +46,4 @@ export const Resources = () => { ); -}; +} diff --git a/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx b/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx index c0dc67b3373b1..02e841ec50ee2 100644 --- a/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx @@ -33,7 +33,7 @@ interface Props { alerts: Alert[]; } -export const AlertsSection = ({ alerts }: Props) => { +export function AlertsSection({ alerts }: Props) { const { core } = usePluginContext(); const [filter, setFilter] = useState(ALL_TYPES); @@ -130,4 +130,4 @@ export const AlertsSection = ({ alerts }: Props) => { ); -}; +} diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx index dce80ed324456..a1d51ffda6afd 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx @@ -30,7 +30,7 @@ function formatTpm(value?: number) { return numeral(value).format('0.00a'); } -export const APMSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { +export function APMSection({ absoluteTime, relativeTime, bucketSize }: Props) { const theme = useContext(ThemeContext); const history = useHistory(); @@ -43,7 +43,7 @@ export const APMSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => bucketSize, }); } - }, [start, end, bucketSize]); + }, [start, end, bucketSize, relativeTime]); const { appLink, stats, series } = data || {}; @@ -121,4 +121,4 @@ export const APMSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => ); -}; +} diff --git a/x-pack/plugins/observability/public/components/app/section/error_panel/index.tsx b/x-pack/plugins/observability/public/components/app/section/error_panel/index.tsx index 8f0781b8f0269..2413580e90a07 100644 --- a/x-pack/plugins/observability/public/components/app/section/error_panel/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/error_panel/index.tsx @@ -7,7 +7,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -export const ErrorPanel = () => { +export function ErrorPanel() { return ( @@ -19,4 +19,4 @@ export const ErrorPanel = () => { ); -}; +} diff --git a/x-pack/plugins/observability/public/components/app/section/index.tsx b/x-pack/plugins/observability/public/components/app/section/index.tsx index 9ba524259ea1c..6c6d107b714be 100644 --- a/x-pack/plugins/observability/public/components/app/section/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/index.tsx @@ -20,7 +20,7 @@ interface Props { appLink?: AppLink; } -export const SectionContainer = ({ title, appLink, children, hasError }: Props) => { +export function SectionContainer({ title, appLink, children, hasError }: Props) { const { core } = usePluginContext(); return ( ); -}; +} diff --git a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx index 9b232ea33cbfb..aa1dc1640125e 100644 --- a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx @@ -45,7 +45,7 @@ function getColorPerItem(series?: LogsFetchDataResponse['series']) { return colorsPerItem; } -export const LogsSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { +export function LogsSection({ absoluteTime, relativeTime, bucketSize }: Props) { const history = useHistory(); const { start, end } = absoluteTime; @@ -57,7 +57,7 @@ export const LogsSection = ({ absoluteTime, relativeTime, bucketSize }: Props) = bucketSize, }); } - }, [start, end, bucketSize]); + }, [start, end, bucketSize, relativeTime]); const min = moment.utc(absoluteTime.start).valueOf(); const max = moment.utc(absoluteTime.end).valueOf(); @@ -160,4 +160,4 @@ export const LogsSection = ({ absoluteTime, relativeTime, bucketSize }: Props) = ); -}; +} diff --git a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx index 9e5fdadaf4e5f..8bce8205902fa 100644 --- a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx @@ -46,7 +46,7 @@ const StyledProgress = styled.div<{ color?: string }>` } `; -export const MetricsSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { +export function MetricsSection({ absoluteTime, relativeTime, bucketSize }: Props) { const theme = useContext(ThemeContext); const { start, end } = absoluteTime; @@ -58,7 +58,7 @@ export const MetricsSection = ({ absoluteTime, relativeTime, bucketSize }: Props bucketSize, }); } - }, [start, end, bucketSize]); + }, [start, end, bucketSize, relativeTime]); const isLoading = status === FETCH_STATUS.LOADING; @@ -162,9 +162,9 @@ export const MetricsSection = ({ absoluteTime, relativeTime, bucketSize }: Props ); -}; +} -const AreaChart = ({ +function AreaChart({ serie, isLoading, color, @@ -172,7 +172,7 @@ const AreaChart = ({ serie?: Series; isLoading: boolean; color: string; -}) => { +}) { const chartTheme = useChartTheme(); return ( @@ -191,4 +191,4 @@ const AreaChart = ({ )} ); -}; +} diff --git a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx index 73a566460a593..cfb06af3224c7 100644 --- a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx @@ -35,7 +35,7 @@ interface Props { bucketSize?: string; } -export const UptimeSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { +export function UptimeSection({ absoluteTime, relativeTime, bucketSize }: Props) { const theme = useContext(ThemeContext); const history = useHistory(); @@ -48,7 +48,7 @@ export const UptimeSection = ({ absoluteTime, relativeTime, bucketSize }: Props) bucketSize, }); } - }, [start, end, bucketSize]); + }, [start, end, bucketSize, relativeTime]); const min = moment.utc(absoluteTime.start).valueOf(); const max = moment.utc(absoluteTime.end).valueOf(); @@ -138,9 +138,9 @@ export const UptimeSection = ({ absoluteTime, relativeTime, bucketSize }: Props) ); -}; +} -const UptimeBarSeries = ({ +function UptimeBarSeries({ id, label, series, @@ -152,7 +152,7 @@ const UptimeBarSeries = ({ series?: Series; color: string; ticktFormatter: TickFormatter; -}) => { +}) { if (!series) { return null; } @@ -188,4 +188,4 @@ const UptimeBarSeries = ({ /> ); -}; +} diff --git a/x-pack/plugins/observability/public/components/app/styled_stat/index.tsx b/x-pack/plugins/observability/public/components/app/styled_stat/index.tsx index fe38df6484c29..a58a0c8309723 100644 --- a/x-pack/plugins/observability/public/components/app/styled_stat/index.tsx +++ b/x-pack/plugins/observability/public/components/app/styled_stat/index.tsx @@ -21,7 +21,7 @@ interface Props extends Partial { const EMPTY_VALUE = '--'; -export const StyledStat = (props: Props) => { +export function StyledStat(props: Props) { const { description = EMPTY_VALUE, title = EMPTY_VALUE, ...rest } = props; return ; -}; +} 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 ea79f4d08d701..55746ff6576a9 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 @@ -14,37 +14,45 @@ import { EuiPopoverProps, } from '@elastic/eui'; -import React, { HTMLAttributes } from 'react'; +import React, { HTMLAttributes, ReactNode } from 'react'; import { EuiListGroupItemProps } from '@elastic/eui/src/components/list_group/list_group_item'; import styled from 'styled-components'; type Props = EuiPopoverProps & HTMLAttributes; -export const SectionTitle: React.FC<{}> = (props) => ( - <> - -
{props.children}
-
- - -); - -export const SectionSubtitle: React.FC<{}> = (props) => ( - <> - - {props.children} - - - -); - -export const SectionLinks: React.FC<{}> = (props) => ( - - {props.children} - -); - -export const SectionSpacer: React.FC<{}> = () => ; +export function SectionTitle({ children }: { children?: ReactNode }) { + return ( + <> + +
{children}
+
+ + + ); +} + +export function SectionSubtitle({ children }: { children?: ReactNode }) { + return ( + <> + + {children} + + + + ); +} + +export function SectionLinks({ children }: { children?: ReactNode }) { + return ( + + {children} + + ); +} + +export function SectionSpacer() { + return ; +} export const Section = styled.div` margin-bottom: 24px; @@ -54,10 +62,14 @@ export const Section = styled.div` `; export type SectionLinkProps = EuiListGroupItemProps; -export const SectionLink: React.FC = (props) => ( - -); +export function SectionLink(props: SectionLinkProps) { + return ; +} -export const ActionMenuDivider: React.FC<{}> = (props) => ; +export function ActionMenuDivider() { + return ; +} -export const ActionMenu: React.FC = (props) => ; +export function ActionMenu(props: Props) { + return ; +} diff --git a/x-pack/plugins/observability/public/components/shared/data_picker/index.tsx b/x-pack/plugins/observability/public/components/shared/data_picker/index.tsx index cc77c1ed72b4a..1c4f465a1d301 100644 --- a/x-pack/plugins/observability/public/components/shared/data_picker/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/data_picker/index.tsx @@ -31,7 +31,7 @@ interface Props { refreshInterval: number; } -export const DatePicker = ({ rangeFrom, rangeTo, refreshPaused, refreshInterval }: Props) => { +export function DatePicker({ rangeFrom, rangeTo, refreshPaused, refreshInterval }: Props) { const location = useLocation(); const history = useHistory(); @@ -86,4 +86,4 @@ export const DatePicker = ({ rangeFrom, rangeTo, refreshPaused, refreshInterval onRefresh={onTimeChange} /> ); -}; +} diff --git a/x-pack/plugins/observability/public/pages/home/index.tsx b/x-pack/plugins/observability/public/pages/home/index.tsx index 59513fc047f17..349533346f2ad 100644 --- a/x-pack/plugins/observability/public/pages/home/index.tsx +++ b/x-pack/plugins/observability/public/pages/home/index.tsx @@ -8,7 +8,7 @@ import { useHistory } from 'react-router-dom'; import { fetchHasData } from '../../data_handler'; import { useFetcher } from '../../hooks/use_fetcher'; -export const HomePage = () => { +export function HomePage() { const history = useHistory(); const { data = {} } = useFetcher(() => fetchHasData(), []); @@ -23,4 +23,4 @@ export const HomePage = () => { } return <>; -}; +} diff --git a/x-pack/plugins/observability/public/pages/landing/index.tsx b/x-pack/plugins/observability/public/pages/landing/index.tsx index 81485953f8713..4d8bd4bf2c789 100644 --- a/x-pack/plugins/observability/public/pages/landing/index.tsx +++ b/x-pack/plugins/observability/public/pages/landing/index.tsx @@ -27,7 +27,7 @@ const EuiCardWithoutPadding = styled(EuiCard)` padding: 0; `; -export const LandingPage = () => { +export function LandingPage() { const { core } = usePluginContext(); const theme = useContext(ThemeContext); @@ -124,4 +124,4 @@ export const LandingPage = () => { ); -}; +} diff --git a/x-pack/plugins/observability/public/pages/overview/index.tsx b/x-pack/plugins/observability/public/pages/overview/index.tsx index 088fab032d930..32bdb00577bd2 100644 --- a/x-pack/plugins/observability/public/pages/overview/index.tsx +++ b/x-pack/plugins/observability/public/pages/overview/index.tsx @@ -38,14 +38,14 @@ function calculatetBucketSize({ start, end }: { start?: number; end?: number }) } } -export const OverviewPage = ({ routeParams }: Props) => { +export function OverviewPage({ routeParams }: Props) { const { core } = usePluginContext(); const { data: alerts = [], status: alertStatus } = useFetcher(() => { return getObservabilityAlerts({ core }); - }, []); + }, [core]); - const { data: newsFeed } = useFetcher(() => getNewsFeed({ core }), []); + const { data: newsFeed } = useFetcher(() => getNewsFeed({ core }), [core]); const theme = useContext(ThemeContext); const timePickerTime = useKibanaUISettings(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS); @@ -206,4 +206,4 @@ export const OverviewPage = ({ routeParams }: Props) => { ); -}; +} diff --git a/x-pack/plugins/observability/public/pages/overview/loading_observability.tsx b/x-pack/plugins/observability/public/pages/overview/loading_observability.tsx index 90e3104443e6b..0f4fa9b864744 100644 --- a/x-pack/plugins/observability/public/pages/overview/loading_observability.tsx +++ b/x-pack/plugins/observability/public/pages/overview/loading_observability.tsx @@ -20,7 +20,7 @@ const CentralizedFlexGroup = styled(EuiFlexGroup)` min-height: calc(100vh - ${(props) => props.theme.eui.euiHeaderChildSize}); `; -export const LoadingObservability = () => { +export function LoadingObservability() { const theme = useContext(ThemeContext); return ( @@ -50,4 +50,4 @@ export const LoadingObservability = () => { ); -}; +} diff --git a/x-pack/plugins/observability/public/typings/eui_styled_components.tsx b/x-pack/plugins/observability/public/typings/eui_styled_components.tsx index aab16f9d79c4b..9e547b58bc736 100644 --- a/x-pack/plugins/observability/public/typings/eui_styled_components.tsx +++ b/x-pack/plugins/observability/public/typings/eui_styled_components.tsx @@ -16,23 +16,25 @@ export interface EuiTheme { darkMode: boolean; } -const EuiThemeProvider = < +function EuiThemeProvider< OuterTheme extends styledComponents.DefaultTheme = styledComponents.DefaultTheme >({ darkMode = false, ...otherProps }: Omit, 'theme'> & { darkMode?: boolean; -}) => ( - ({ - ...outerTheme, - eui: darkMode ? euiDarkVars : euiLightVars, - darkMode, - })} - /> -); +}) { + return ( + ({ + ...outerTheme, + eui: darkMode ? euiDarkVars : euiLightVars, + darkMode, + })} + /> + ); +} const { default: euiStyled, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/mitre/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/mitre/index.tsx index e898a362c7771..71734affd42ce 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/mitre/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/mitre/index.tsx @@ -35,7 +35,7 @@ const MyEuiSuperSelect = styled(EuiSuperSelect)` `; interface AddItemProps { field: FieldHook; - dataTestSubj: string; + dataTestSubj: string; // eslint-disable-line react/no-unused-prop-types idAria: string; isDisabled: boolean; } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.tsx index fd75c229d479d..49fe3438664c6 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.tsx @@ -22,6 +22,7 @@ interface TagsFilterPopoverProps { selectedTags: string[]; tags: string[]; onSelectedTagsChanged: Dispatch>; + // eslint-disable-next-line react/no-unused-prop-types isLoading: boolean; // TO DO reimplement? } diff --git a/yarn.lock b/yarn.lock index 1bb8fab0372ae..fd6019750dda5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7557,6 +7557,15 @@ array-includes@^3.0.3: define-properties "^1.1.2" es-abstract "^1.7.0" +array-includes@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.1.tgz#cdd67e6852bdf9c1215460786732255ed2459348" + integrity sha512-c2VXaCHl7zPsvpkFsw4nxvFie4fh1ur9bpcgsVkIjqn0H/Xwdg+7fv3n2r/isyS8EBj5b06M9kHyZuIr4El6WQ== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0" + is-string "^1.0.5" + array-initial@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/array-initial/-/array-initial-1.1.0.tgz#2fa74b26739371c3947bd7a7adc73be334b3d795" @@ -7643,6 +7652,15 @@ array.prototype.flatmap@^1.2.1: es-abstract "^1.10.0" function-bind "^1.1.1" +array.prototype.flatmap@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.2.3.tgz#1c13f84a178566042dd63de4414440db9222e443" + integrity sha512-OOEk+lkePcg+ODXIpvuU9PAryCikCJyo7GlDG1upleEpQRx6mzL9puEBkozQ5iAx20KV0l3DbyQwqciJtqe5Pg== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + function-bind "^1.1.1" + arraybuffer.slice@~0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675" @@ -13369,39 +13387,39 @@ es-abstract@^1.10.0, es-abstract@^1.11.0, es-abstract@^1.13.0, es-abstract@^1.14 string.prototype.trimleft "^2.1.1" string.prototype.trimright "^2.1.1" -es-abstract@^1.15.0, es-abstract@^1.17.0-next.1: - version "1.17.4" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.4.tgz#e3aedf19706b20e7c2594c35fc0d57605a79e184" - integrity sha512-Ae3um/gb8F0mui/jPL+QiqmglkUsaQf7FwBEHYIFkztkneosu9imhqHpBzQ3h1vit8t5iQ74t6PEVvphBZiuiQ== +es-abstract@^1.17.0, es-abstract@^1.17.4, es-abstract@^1.17.5: + version "1.17.6" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a" + integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw== dependencies: es-to-primitive "^1.2.1" function-bind "^1.1.1" has "^1.0.3" has-symbols "^1.0.1" - is-callable "^1.1.5" - is-regex "^1.0.5" + is-callable "^1.2.0" + is-regex "^1.1.0" object-inspect "^1.7.0" object-keys "^1.1.1" object.assign "^4.1.0" - string.prototype.trimleft "^2.1.1" - string.prototype.trimright "^2.1.1" + string.prototype.trimend "^1.0.1" + string.prototype.trimstart "^1.0.1" -es-abstract@^1.17.4, es-abstract@^1.17.5: - version "1.17.6" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a" - integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw== +es-abstract@^1.17.0-next.1: + version "1.17.4" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.4.tgz#e3aedf19706b20e7c2594c35fc0d57605a79e184" + integrity sha512-Ae3um/gb8F0mui/jPL+QiqmglkUsaQf7FwBEHYIFkztkneosu9imhqHpBzQ3h1vit8t5iQ74t6PEVvphBZiuiQ== dependencies: es-to-primitive "^1.2.1" function-bind "^1.1.1" has "^1.0.3" has-symbols "^1.0.1" - is-callable "^1.2.0" - is-regex "^1.1.0" + is-callable "^1.1.5" + is-regex "^1.0.5" object-inspect "^1.7.0" object-keys "^1.1.1" object.assign "^4.1.0" - string.prototype.trimend "^1.0.1" - string.prototype.trimstart "^1.0.1" + string.prototype.trimleft "^2.1.1" + string.prototype.trimright "^2.1.1" es-get-iterator@^1.1.0: version "1.1.0" @@ -13713,11 +13731,6 @@ eslint-plugin-es@^3.0.0: eslint-utils "^2.0.0" regexpp "^3.0.0" -eslint-plugin-eslint-plugin@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-eslint-plugin/-/eslint-plugin-eslint-plugin-2.1.0.tgz#a7a00f15a886957d855feacaafee264f039e62d5" - integrity sha512-kT3A/ZJftt28gbl/Cv04qezb/NQ1dwYIbi8lyf806XMxkus7DvOVCLIfTXMrorp322Pnoez7+zabXH29tADIDg== - eslint-plugin-import@^2.19.1: version "2.19.1" resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.19.1.tgz#5654e10b7839d064dd0d46cd1b88ec2133a11448" @@ -13804,21 +13817,22 @@ eslint-plugin-react-perf@^3.2.3: resolved "https://registry.yarnpkg.com/eslint-plugin-react-perf/-/eslint-plugin-react-perf-3.2.3.tgz#e28d42d3a1f7ec3c8976a94735d8e17e7d652a45" integrity sha512-bMiPt7uywwS1Ly25n752NE3Ei0XBZ3igplTkZ8GPJKyZVVUd3cHgzILGeQW2HIeAkzQ9zwk9HM6EcYDipdFk3Q== -eslint-plugin-react@^7.17.0: - version "7.17.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.17.0.tgz#a31b3e134b76046abe3cd278e7482bd35a1d12d7" - integrity sha512-ODB7yg6lxhBVMeiH1c7E95FLD4E/TwmFjltiU+ethv7KPdCwgiFuOZg9zNRHyufStTDLl/dEFqI2Q1VPmCd78A== +eslint-plugin-react@^7.20.3: + version "7.20.3" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.20.3.tgz#0590525e7eb83890ce71f73c2cf836284ad8c2f1" + integrity sha512-txbo090buDeyV0ugF3YMWrzLIUqpYTsWSDZV9xLSmExE1P/Kmgg9++PD931r+KEWS66O1c9R4srLVVHmeHpoAg== dependencies: - array-includes "^3.0.3" + array-includes "^3.1.1" + array.prototype.flatmap "^1.2.3" doctrine "^2.1.0" - eslint-plugin-eslint-plugin "^2.1.0" has "^1.0.3" - jsx-ast-utils "^2.2.3" - object.entries "^1.1.0" - object.fromentries "^2.0.1" - object.values "^1.1.0" + jsx-ast-utils "^2.4.1" + object.entries "^1.1.2" + object.fromentries "^2.0.2" + object.values "^1.1.1" prop-types "^15.7.2" - resolve "^1.13.1" + resolve "^1.17.0" + string.prototype.matchall "^4.0.2" eslint-rule-composer@^0.3.0: version "0.3.0" @@ -18059,6 +18073,15 @@ internal-ip@^4.3.0: default-gateway "^4.2.0" ipaddr.js "^1.9.0" +internal-slot@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.2.tgz#9c2e9fb3cd8e5e4256c6f45fe310067fcfa378a3" + integrity sha512-2cQNfwhAfJIkU4KZPkDI+Gj5yNNnbqi40W9Gge6dfnk4TocEVm00B3bdiL+JINrbGJil2TeHvM4rETGzk/f/0g== + dependencies: + es-abstract "^1.17.0-next.1" + has "^1.0.3" + side-channel "^1.0.2" + interpret@1.2.0, interpret@^1.0.0, interpret@^1.1.0, interpret@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" @@ -20100,12 +20123,12 @@ jsx-ast-utils@^2.2.1: array-includes "^3.0.3" object.assign "^4.1.0" -jsx-ast-utils@^2.2.3: - version "2.2.3" - resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.2.3.tgz#8a9364e402448a3ce7f14d357738310d9248054f" - integrity sha512-EdIHFMm+1BPynpKOpdPqiOsvnIrInRGJD7bzPZdPkjitQEqpdpUuFpq4T0npZFKTiB3RhWFdGN+oqOJIdhDhQA== +jsx-ast-utils@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.4.1.tgz#1114a4c1209481db06c690c2b4f488cc665f657e" + integrity sha512-z1xSldJ6imESSzOjd3NNkieVJKRlKYSOtMG8SFyCj2FIrvSaSuli/WjpBkEzCBoR9bYYYFgqJw61Xhu7Lcgk+w== dependencies: - array-includes "^3.0.3" + array-includes "^3.1.1" object.assign "^4.1.0" jsx-to-string@^1.4.0: @@ -23352,6 +23375,15 @@ object.entries@^1.0.4, object.entries@^1.1.0, object.entries@^1.1.1: function-bind "^1.1.1" has "^1.0.3" +object.entries@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.2.tgz#bc73f00acb6b6bb16c203434b10f9a7e797d3add" + integrity sha512-BQdB9qKmb/HyNdMNWVr7O3+z5MUIx3aiegEIJqjMBbBf0YT9RRxTJSim4mzFqtyr7PDAHigq0N9dO0m0tRakQA== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + has "^1.0.3" + object.fromentries@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-1.0.0.tgz#e90ec27445ec6e37f48be9af9077d9aa8bef0d40" @@ -23362,16 +23394,6 @@ object.fromentries@^1.0.0: function-bind "^1.1.1" has "^1.0.1" -object.fromentries@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.1.tgz#050f077855c7af8ae6649f45c80b16ee2d31e704" - integrity sha512-PUQv8Hbg3j2QX0IQYv3iAGCbGcu4yY4KQ92/dhA4sFSixBmSmp13UpDLs6jGK8rBtbmhNNIK99LD2k293jpiGA== - dependencies: - define-properties "^1.1.3" - es-abstract "^1.15.0" - function-bind "^1.1.1" - has "^1.0.3" - object.fromentries@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.2.tgz#4a09c9b9bb3843dd0f89acdb517a794d4f355ac9" @@ -27505,7 +27527,7 @@ resolve@1.8.1: dependencies: path-parse "^1.0.5" -resolve@^1.1.10, resolve@^1.1.5, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.0, resolve@^1.11.1, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.17.0, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.7.1, resolve@^1.8.1: +resolve@^1.1.10, resolve@^1.1.5, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.0, resolve@^1.11.1, resolve@^1.12.0, resolve@^1.17.0, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.7.1, resolve@^1.8.1: version "1.17.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== @@ -29396,6 +29418,18 @@ string.prototype.matchall@^3.0.0: has-symbols "^1.0.0" regexp.prototype.flags "^1.2.0" +string.prototype.matchall@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.2.tgz#48bb510326fb9fdeb6a33ceaa81a6ea04ef7648e" + integrity sha512-N/jp6O5fMf9os0JU3E72Qhf590RSRZU/ungsL/qJUYVTNv7hTG0P/dbPjxINVN9jpscu3nzYwKESU3P3RY5tOg== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0" + has-symbols "^1.0.1" + internal-slot "^1.0.2" + regexp.prototype.flags "^1.3.0" + side-channel "^1.0.2" + string.prototype.padend@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/string.prototype.padend/-/string.prototype.padend-3.0.0.tgz#f3aaef7c1719f170c5eab1c32bf780d96e21f2f0" From 76150a4026c161fc4a264e83724c576917f2fb5f Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Mon, 27 Jul 2020 08:20:27 -0500 Subject: [PATCH 10/17] Observability i18n fixes (#72984) * Format `xpack.apm.percentOfParent` correctly so the transactions page in APM doesn't crash. In English it reads like, "(X% of transaction)". I'm not sure what the intention of the changed translation is, but I've changed it to be the equivalent of "(X% transaction)". A correction to the Japanese form here would be appreciated, but this fixes the crash and gets the message across. * Format `xpack.infra.logs.customizeLogs.textSizeRadioGroup` correctly. This was not causing the whole logs page to crash, but was causing an error in the JS console. --- x-pack/plugins/translations/translations/ja-JP.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index cf789d1e7c450..8baebbb4939be 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4257,7 +4257,7 @@ "xpack.apm.metrics.transactionChart.transactionDurationLabel": "トランザクション時間", "xpack.apm.metrics.transactionChart.transactionsPerMinuteLabel": "1 分あたりのトランザクション数", "xpack.apm.notAvailableLabel": "N/A", - "xpack.apm.percentOfParent": "({parentType, select, transaction { 件中 {value} 件のトランザクション} トレース {trace} })", + "xpack.apm.percentOfParent": "({value} {parentType, select, transaction {トランザクション} trace {トレース} })", "xpack.apm.propertiesTable.agentFeature.noDataAvailableLabel": "利用可能なデータがありません", "xpack.apm.propertiesTable.agentFeature.noResultFound": "\"{value}\"に対する結果が見つかりませんでした。", "xpack.apm.propertiesTable.tabs.exceptionStacktraceLabel": "例外のスタックトレース", @@ -7430,7 +7430,7 @@ "xpack.infra.logs.customizeLogs.customizeButtonLabel": "カスタマイズ", "xpack.infra.logs.customizeLogs.lineWrappingFormRowLabel": "改行", "xpack.infra.logs.customizeLogs.textSizeFormRowLabel": "テキストサイズ", - "xpack.infra.logs.customizeLogs.textSizeRadioGroup": "{textScale, select, small {小さい} 中くらい {Medium} 大きい {Large} その他の {{textScale}} }", + "xpack.infra.logs.customizeLogs.textSizeRadioGroup": "{textScale, select, small {小さい} medium {中くらい} large {大きい} other {{textScale}}}", "xpack.infra.logs.customizeLogs.wrapLongLinesSwitchLabel": "長い行を改行", "xpack.infra.logs.emptyView.checkForNewDataButtonLabel": "新規データを確認", "xpack.infra.logs.emptyView.noLogMessageDescription": "フィルターを調整してみてください。", From bc65c5e160031e8e93e77e2bab72574ae7fefe9b Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 27 Jul 2020 16:40:02 +0300 Subject: [PATCH 11/17] [Security Solution][Cases] Create useAllCasesModal hook (#72602) --- .../cases/components/all_cases/index.test.tsx | 148 +++++++++++++++--- .../cases/components/all_cases/index.tsx | 93 +++++++---- .../components/all_cases_modal/index.tsx | 1 + .../all_cases_modal.test.tsx | 74 +++++++++ .../use_all_cases_modal/all_cases_modal.tsx | 47 ++++++ .../use_all_cases_modal/index.test.tsx | 143 +++++++++++++++++ .../components/use_all_cases_modal/index.tsx | 85 ++++++++++ .../use_all_cases_modal/translations.ts | 10 ++ .../common/lib/kibana/__mocks__/index.ts | 1 + .../components/graph_overlay/index.tsx | 48 ++---- .../components/timeline/properties/index.tsx | 46 +----- 11 files changed, 567 insertions(+), 129 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/translations.ts 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 23cabd6778cc0..f5ed151ebac3c 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 @@ -14,6 +14,8 @@ import { TestProviders } from '../../../common/mock'; import { useGetCasesMockState } from '../../containers/mock'; import * as i18n from './translations'; +import { useKibana } from '../../../common/lib/kibana'; +import { createUseKibanaMock } from '../../../common/mock/kibana_react'; import { getEmptyTagValue } from '../../../common/components/empty_value'; import { useDeleteCases } from '../../containers/use_delete_cases'; import { useGetCases } from '../../containers/use_get_cases'; @@ -26,6 +28,7 @@ jest.mock('../../containers/use_delete_cases'); jest.mock('../../containers/use_get_cases'); jest.mock('../../containers/use_get_cases_status'); +const useKibanaMock = useKibana as jest.Mock; const useDeleteCasesMock = useDeleteCases as jest.Mock; const useGetCasesMock = useGetCases as jest.Mock; const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; @@ -33,6 +36,8 @@ const useUpdateCasesMock = useUpdateCases as jest.Mock; jest.mock('../../../common/components/link_to'); +jest.mock('../../../common/lib/kibana'); + describe('AllCases', () => { const dispatchResetIsDeleted = jest.fn(); const dispatchResetIsUpdated = jest.fn(); @@ -45,6 +50,7 @@ describe('AllCases', () => { const setSelectedCases = jest.fn(); const updateBulkStatus = jest.fn(); const fetchCasesStatus = jest.fn(); + const onRowClick = jest.fn(); const emptyTag = getEmptyTagValue().props.children; const defaultGetCases = { @@ -77,6 +83,9 @@ describe('AllCases', () => { dispatchResetIsUpdated, updateBulkStatus, }; + + let navigateToApp: jest.Mock; + /* eslint-disable no-console */ // Silence until enzyme fixed to use ReactTestUtils.act() const originalError = console.error; @@ -89,10 +98,20 @@ describe('AllCases', () => { /* eslint-enable no-console */ beforeEach(() => { jest.resetAllMocks(); - useUpdateCasesMock.mockImplementation(() => defaultUpdateCases); - useGetCasesMock.mockImplementation(() => defaultGetCases); - useDeleteCasesMock.mockImplementation(() => defaultDeleteCases); - useGetCasesStatusMock.mockImplementation(() => defaultCasesStatus); + navigateToApp = jest.fn(); + const kibanaMock = createUseKibanaMock()(); + useKibanaMock.mockReturnValue({ + ...kibanaMock, + services: { + application: { + navigateToApp, + }, + }, + }); + useUpdateCasesMock.mockReturnValue(defaultUpdateCases); + useGetCasesMock.mockReturnValue(defaultGetCases); + useDeleteCasesMock.mockReturnValue(defaultDeleteCases); + useGetCasesStatusMock.mockReturnValue(defaultCasesStatus); moment.tz.setDefault('UTC'); }); it('should render AllCases', () => { @@ -125,7 +144,7 @@ describe('AllCases', () => { ); }); it('should render empty fields', () => { - useGetCasesMock.mockImplementation(() => ({ + useGetCasesMock.mockReturnValue({ ...defaultGetCases, data: { ...defaultGetCases.data, @@ -141,7 +160,7 @@ describe('AllCases', () => { }, ], }, - })); + }); const wrapper = mount( @@ -202,10 +221,10 @@ describe('AllCases', () => { }); }); it('opens case when row action icon clicked', () => { - useGetCasesMock.mockImplementation(() => ({ + useGetCasesMock.mockReturnValue({ ...defaultGetCases, filterOptions: { ...defaultGetCases.filterOptions, status: 'closed' }, - })); + }); const wrapper = mount( @@ -223,10 +242,11 @@ describe('AllCases', () => { }); }); it('Bulk delete', () => { - useGetCasesMock.mockImplementation(() => ({ + useGetCasesMock.mockReturnValue({ ...defaultGetCases, selectedCases: useGetCasesMockState.data.cases, - })); + }); + useDeleteCasesMock .mockReturnValueOnce({ ...defaultDeleteCases, @@ -257,10 +277,10 @@ describe('AllCases', () => { ); }); it('Bulk close status update', () => { - useGetCasesMock.mockImplementation(() => ({ + useGetCasesMock.mockReturnValue({ ...defaultGetCases, selectedCases: useGetCasesMockState.data.cases, - })); + }); const wrapper = mount( @@ -272,14 +292,14 @@ describe('AllCases', () => { expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'closed'); }); it('Bulk open status update', () => { - useGetCasesMock.mockImplementation(() => ({ + useGetCasesMock.mockReturnValue({ ...defaultGetCases, selectedCases: useGetCasesMockState.data.cases, filterOptions: { ...defaultGetCases.filterOptions, status: 'closed', }, - })); + }); const wrapper = mount( @@ -291,10 +311,10 @@ describe('AllCases', () => { expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'open'); }); it('isDeleted is true, refetch', () => { - useDeleteCasesMock.mockImplementation(() => ({ + useDeleteCasesMock.mockReturnValue({ ...defaultDeleteCases, isDeleted: true, - })); + }); mount( @@ -306,10 +326,10 @@ describe('AllCases', () => { expect(dispatchResetIsDeleted).toBeCalled(); }); it('isUpdated is true, refetch', () => { - useUpdateCasesMock.mockImplementation(() => ({ + useUpdateCasesMock.mockReturnValue({ ...defaultUpdateCases, isUpdated: true, - })); + }); mount( @@ -320,4 +340,96 @@ describe('AllCases', () => { expect(fetchCasesStatus).toBeCalled(); expect(dispatchResetIsUpdated).toBeCalled(); }); + + it('should not render header when modal=true', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="all-cases-header"]').exists()).toBe(false); + }); + + it('should not render table utility bar when modal=true', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="case-table-utility-bar-actions"]').exists()).toBe(false); + }); + + it('case table should not be selectable when modal=true', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="cases-table"]').first().prop('isSelectable')).toBe(false); + }); + + it('should call onRowClick with no cases and modal=true', () => { + useGetCasesMock.mockReturnValue({ + ...defaultGetCases, + data: { + ...defaultGetCases.data, + total: 0, + cases: [], + }, + }); + + const wrapper = mount( + + + + ); + + wrapper.find('[data-test-subj="cases-table-add-case"]').first().simulate('click'); + expect(onRowClick).toHaveBeenCalled(); + }); + + it('should call navigateToApp with no cases and modal=false', () => { + useGetCasesMock.mockReturnValue({ + ...defaultGetCases, + data: { + ...defaultGetCases.data, + total: 0, + cases: [], + }, + }); + + const wrapper = mount( + + + + ); + + wrapper.find('[data-test-subj="cases-table-add-case"]').first().simulate('click'); + expect(navigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/create' }); + }); + + it('should call onRowClick when clicking a case with modal=true', () => { + const wrapper = mount( + + + + ); + + wrapper.find('[data-test-subj="cases-table-row-1"]').first().simulate('click'); + expect(onRowClick).toHaveBeenCalledWith('1'); + }); + + it('should NOT call onRowClick when clicking a case with modal=true', () => { + const wrapper = mount( + + + + ); + + wrapper.find('[data-test-subj="cases-table-row-1"]').first().simulate('click'); + expect(onRowClick).not.toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx index f46dd9e858c7f..42a87de2aa07b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -/* eslint-disable react-hooks/exhaustive-deps */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { EuiBasicTable, @@ -16,7 +15,7 @@ import { EuiTableSortingType, } from '@elastic/eui'; import { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types'; -import { isEmpty } from 'lodash/fp'; +import { isEmpty, memoize } from 'lodash/fp'; import styled, { css } from 'styled-components'; import * as i18n from './translations'; @@ -137,7 +136,7 @@ export const AllCases = React.memo( (refetchFilter: () => void) => { filterRefetch.current = refetchFilter; }, - [filterRefetch.current] + [filterRefetch] ); const refreshCases = useCallback( (dataRefresh = true) => { @@ -149,7 +148,7 @@ export const AllCases = React.memo( filterRefetch.current(); } }, - [filterOptions, queryParams, filterRefetch.current] + [filterRefetch, refetchCases, setSelectedCases, fetchCasesStatus] ); useEffect(() => { @@ -161,7 +160,7 @@ export const AllCases = React.memo( refreshCases(); dispatchResetIsUpdated(); } - }, [isDeleted, isUpdated]); + }, [isDeleted, isUpdated, refreshCases, dispatchResetIsDeleted, dispatchResetIsUpdated]); const confirmDeleteModal = useMemo( () => ( ( )} /> ), - [deleteBulk, deleteThisCase, isDisplayConfirmDeleteModal] + [ + deleteBulk, + deleteThisCase, + isDisplayConfirmDeleteModal, + handleToggleModal, + handleOnDeleteConfirm, + ] ); - const toggleDeleteModal = useCallback((deleteCase: Case) => { - handleToggleModal(); - setDeleteThisCase(deleteCase); - }, []); + const toggleDeleteModal = useCallback( + (deleteCase: Case) => { + handleToggleModal(); + setDeleteThisCase(deleteCase); + }, + [handleToggleModal] + ); const toggleBulkDeleteModal = useCallback( (caseIds: string[]) => { @@ -195,14 +203,14 @@ export const AllCases = React.memo( const convertToDeleteCases: DeleteCase[] = caseIds.map((id) => ({ id })); setDeleteBulk(convertToDeleteCases); }, - [selectedCases] + [selectedCases, setDeleteBulk, handleToggleModal] ); const handleUpdateCaseStatus = useCallback( (status: string) => { updateBulkStatus(selectedCases, status); }, - [selectedCases] + [selectedCases, updateBulkStatus] ); const selectedCaseIds = useMemo( @@ -223,7 +231,7 @@ export const AllCases = React.memo( })} /> ), - [selectedCaseIds, filterOptions.status, toggleBulkDeleteModal] + [selectedCaseIds, filterOptions.status, toggleBulkDeleteModal, handleUpdateCaseStatus] ); const handleDispatchUpdate = useCallback( (args: Omit) => { @@ -278,7 +286,7 @@ export const AllCases = React.memo( setQueryParams(newQueryParams); refreshCases(false); }, - [queryParams] + [queryParams, refreshCases, setQueryParams] ); const onFilterChangedCallback = useCallback( @@ -291,7 +299,7 @@ export const AllCases = React.memo( setFilters(newFilterOptions); refreshCases(false); }, - [filterOptions, queryParams] + [refreshCases, setQueryParams, setFilters] ); const memoizedGetCasesColumns = useMemo( @@ -311,9 +319,10 @@ export const AllCases = React.memo( const sorting: EuiTableSortingType = { sort: { field: queryParams.sortField, direction: queryParams.sortOrder }, }; + const euiBasicTableSelectionProps = useMemo>( () => ({ onSelectionChange: setSelectedCases }), - [selectedCases] + [setSelectedCases] ); const isCasesLoading = useMemo( () => loading.indexOf('cases') > -1 || loading.indexOf('caseUpdate') > -1, @@ -322,6 +331,35 @@ export const AllCases = React.memo( const isDataEmpty = useMemo(() => data.total === 0, [data]); const TableWrap = useMemo(() => (isModal ? 'span' : Panel), [isModal]); + + const onTableRowClick = useMemo( + () => + memoize<(id: string) => () => void>((id) => () => { + if (onRowClick) { + onRowClick(id); + } + }), + [onRowClick] + ); + + const tableRowProps = useCallback( + (item) => { + const rowProps = { + 'data-test-subj': `cases-table-row-${item.id}`, + }; + + if (isModal) { + return { + ...rowProps, + onClick: onTableRowClick(item.id), + }; + } + + return rowProps; + }, + [isModal, onTableRowClick] + ); + return ( <> {!isEmpty(actionsErrors) && ( @@ -329,7 +367,13 @@ export const AllCases = React.memo( )} {!isModal && ( - + ( {!isModal && ( - + {i18n.SHOWING_SELECTED_CASES(selectedCases.length)} @@ -441,6 +485,7 @@ export const AllCases = React.memo( onClick={goToCreateCase} href={formatUrl(getCreateCaseUrl())} iconType="plusInCircle" + data-test-subj="cases-table-add-case" > {i18n.ADD_NEW_CASE} @@ -449,17 +494,7 @@ export const AllCases = React.memo( } onChange={tableOnChangeCallback} pagination={memoizedPagination} - rowProps={(item) => - isModal - ? { - onClick: () => { - if (onRowClick != null) { - onRowClick(item.id); - } - }, - } - : {} - } + rowProps={tableRowProps} selection={userCanCrud && !isModal ? euiBasicTableSelectionProps : undefined} sorting={sorting} /> diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx index d8f2e5293ee1b..efbe3e667c27b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx @@ -12,6 +12,7 @@ import { EuiModalHeaderTitle, EuiOverlayMask, } from '@elastic/eui'; + import { useGetUserSavedObjectPermissions } from '../../../common/lib/kibana'; import { AllCases } from '../all_cases'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.test.tsx new file mode 100644 index 0000000000000..6039fec2464cc --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.test.tsx @@ -0,0 +1,74 @@ +/* + * 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 { mount } from 'enzyme'; +import React from 'react'; +import '../../../common/mock/match_media'; +import { AllCasesModal } from './all_cases_modal'; +import { TestProviders } from '../../../common/mock'; + +jest.mock('../all_cases', () => { + const AllCases = () => { + return <>; + }; + return { AllCases }; +}); + +jest.mock('../../../common/lib/kibana', () => { + const originalModule = jest.requireActual('../../../common/lib/kibana'); + return { + ...originalModule, + useGetUserSavedObjectPermissions: jest.fn(), + }; +}); + +const onCloseCaseModal = jest.fn(); +const onRowClick = jest.fn(); +const defaultProps = { + onCloseCaseModal, + onRowClick, +}; + +describe('AllCasesModal', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeTruthy(); + }); + + it('Closing modal calls onCloseCaseModal', () => { + const wrapper = mount( + + + + ); + + wrapper.find('.euiModal__closeIcon').first().simulate('click'); + expect(onCloseCaseModal).toBeCalled(); + }); + + it('pass the correct props to AllCases component', () => { + const wrapper = mount( + + + + ); + + const props = wrapper.find('AllCases').props(); + expect(props).toEqual({ + userCanCrud: false, + onRowClick, + isModal: true, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx new file mode 100644 index 0000000000000..7a12f9211e969 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx @@ -0,0 +1,47 @@ +/* + * 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, { memo } from 'react'; +import { + EuiModal, + EuiModalBody, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, +} from '@elastic/eui'; + +import { useGetUserSavedObjectPermissions } from '../../../common/lib/kibana'; +import { AllCases } from '../all_cases'; +import * as i18n from './translations'; + +export interface AllCasesModalProps { + onCloseCaseModal: () => void; + onRowClick: (id?: string) => void; +} + +const AllCasesModalComponent: React.FC = ({ + onCloseCaseModal, + onRowClick, +}: AllCasesModalProps) => { + const userPermissions = useGetUserSavedObjectPermissions(); + const userCanCrud = userPermissions?.crud ?? false; + return ( + + + + {i18n.SELECT_CASE_TITLE} + + + + + + + ); +}; + +export const AllCasesModal = memo(AllCasesModalComponent); + +AllCasesModal.displayName = 'AllCasesModal'; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx new file mode 100644 index 0000000000000..b5bf68cbf6dc8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable react/display-name */ + +import React from 'react'; +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../common/lib/kibana'; +import '../../../common/mock/match_media'; +import { TimelineId } from '../../../../common/types/timeline'; +import { useAllCasesModal, UseAllCasesModalProps, UseAllCasesModalReturnedValues } from '.'; +import { TestProviders } from '../../../common/mock'; +import { createUseKibanaMock } from '../../../common/mock/kibana_react'; + +jest.mock('../../../common/lib/kibana'); + +const useKibanaMock = useKibana as jest.Mock; + +describe('useAllCasesModal', () => { + const navigateToApp = jest.fn(() => Promise.resolve()); + + beforeEach(() => { + jest.clearAllMocks(); + const kibanaMock = createUseKibanaMock()(); + useKibanaMock.mockImplementation(() => ({ + ...kibanaMock, + services: { + application: { + navigateToApp, + }, + }, + })); + }); + + it('init', async () => { + const { result } = renderHook( + () => useAllCasesModal({ timelineId: TimelineId.test }), + { + wrapper: ({ children }) => {children}, + } + ); + + expect(result.current.showModal).toBe(false); + }); + + it('opens the modal', async () => { + const { result } = renderHook( + () => useAllCasesModal({ timelineId: TimelineId.test }), + { + wrapper: ({ children }) => {children}, + } + ); + + act(() => { + result.current.onOpenModal(); + }); + + expect(result.current.showModal).toBe(true); + }); + + it('closes the modal', async () => { + const { result } = renderHook( + () => useAllCasesModal({ timelineId: TimelineId.test }), + { + wrapper: ({ children }) => {children}, + } + ); + + act(() => { + result.current.onOpenModal(); + result.current.onCloseModal(); + }); + + expect(result.current.showModal).toBe(false); + }); + + it('returns a memoized value', async () => { + const { result, rerender } = renderHook( + () => useAllCasesModal({ timelineId: TimelineId.test }), + { + wrapper: ({ children }) => {children}, + } + ); + + const result1 = result.current; + act(() => rerender()); + const result2 = result.current; + + expect(result1).toBe(result2); + }); + + it('closes the modal when clicking a row', async () => { + const { result } = renderHook( + () => useAllCasesModal({ timelineId: TimelineId.test }), + { + wrapper: ({ children }) => {children}, + } + ); + + act(() => { + result.current.onOpenModal(); + result.current.onRowClick(); + }); + + expect(result.current.showModal).toBe(false); + }); + + it('navigates to the correct path without id', async () => { + const { result } = renderHook( + () => useAllCasesModal({ timelineId: TimelineId.test }), + { + wrapper: ({ children }) => {children}, + } + ); + + act(() => { + result.current.onOpenModal(); + result.current.onRowClick(); + }); + + expect(navigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/create' }); + }); + + it('navigates to the correct path with id', async () => { + const { result } = renderHook( + () => useAllCasesModal({ timelineId: TimelineId.test }), + { + wrapper: ({ children }) => {children}, + } + ); + + act(() => { + result.current.onOpenModal(); + result.current.onRowClick('case-id'); + }); + + expect(navigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/case-id' }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx new file mode 100644 index 0000000000000..f7fc7963b3844 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.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 React, { useState, useCallback, useMemo } from 'react'; + +import { useDispatch, useSelector } from 'react-redux'; +import { APP_ID } from '../../../../common/constants'; +import { SecurityPageName } from '../../../app/types'; +import { useKibana } from '../../../common/lib/kibana'; +import { getCaseDetailsUrl, getCreateCaseUrl } from '../../../common/components/link_to'; +import { State } from '../../../common/store'; +import { setInsertTimeline } from '../../../timelines/store/timeline/actions'; +import { timelineSelectors } from '../../../timelines/store/timeline'; + +import { AllCasesModal } from './all_cases_modal'; + +export interface UseAllCasesModalProps { + timelineId: string; +} + +export interface UseAllCasesModalReturnedValues { + Modal: React.FC; + showModal: boolean; + onCloseModal: () => void; + onOpenModal: () => void; + onRowClick: (id?: string) => void; +} + +export const useAllCasesModal = ({ + timelineId, +}: UseAllCasesModalProps): UseAllCasesModalReturnedValues => { + const dispatch = useDispatch(); + const { navigateToApp } = useKibana().services.application; + const timeline = useSelector((state: State) => + timelineSelectors.selectTimeline(state, timelineId) + ); + + const [showModal, setShowModal] = useState(false); + const onCloseModal = useCallback(() => setShowModal(false), []); + const onOpenModal = useCallback(() => setShowModal(true), []); + + const onRowClick = useCallback( + async (id?: string) => { + onCloseModal(); + + await navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { + path: id != null ? getCaseDetailsUrl({ id }) : getCreateCaseUrl(), + }); + + dispatch( + setInsertTimeline({ + graphEventId: timeline.graphEventId ?? '', + timelineId, + timelineSavedObjectId: timeline.savedObjectId ?? '', + timelineTitle: timeline.title, + }) + ); + }, + // dispatch causes unnecessary rerenders + // eslint-disable-next-line react-hooks/exhaustive-deps + [timeline, navigateToApp, onCloseModal, timelineId] + ); + + const Modal: React.FC = useCallback( + () => + showModal ? : null, + [onCloseModal, onRowClick, showModal] + ); + + const state = useMemo( + () => ({ + Modal, + showModal, + onCloseModal, + onOpenModal, + onRowClick, + }), + [showModal, onCloseModal, onOpenModal, onRowClick, Modal] + ); + + return state; +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/translations.ts b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/translations.ts new file mode 100644 index 0000000000000..e0f84d8541424 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/translations.ts @@ -0,0 +1,10 @@ +/* + * 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'; +export const SELECT_CASE_TITLE = i18n.translate('xpack.securitySolution.case.caseModal.title', { + defaultMessage: 'Select case to attach timeline', +}); diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts index 6ada887ece175..2c52acd3ec747 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts @@ -24,3 +24,4 @@ export const useToasts = jest.fn(() => notificationServiceMock.createStartContra export const useCurrentUser = jest.fn(); export const withKibana = jest.fn(createWithKibanaMock()); export const KibanaContextProvider = jest.fn(createKibanaContextProviderMock()); +export const useGetUserSavedObjectPermissions = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index 54b30aca44a1f..97d1d11395c7f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -13,18 +13,14 @@ import { EuiToolTip, } from '@elastic/eui'; import { noop } from 'lodash/fp'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { connect, ConnectedProps, useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; -import { SecurityPageName } from '../../../app/types'; import { FULL_SCREEN } from '../timeline/body/column_headers/translations'; -import { AllCasesModal } from '../../../cases/components/all_cases_modal'; import { EXIT_FULL_SCREEN } from '../../../common/components/exit_full_screen/translations'; -import { APP_ID, FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; +import { FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; import { useFullScreen } from '../../../common/containers/use_full_screen'; -import { getCaseDetailsUrl, getCreateCaseUrl } from '../../../common/components/link_to'; -import { useKibana } from '../../../common/lib/kibana'; import { State } from '../../../common/store'; import { TimelineId, TimelineType } from '../../../../common/types/timeline'; import { timelineSelectors } from '../../store/timeline'; @@ -32,12 +28,9 @@ import { timelineDefaults } from '../../store/timeline/defaults'; import { TimelineModel } from '../../store/timeline/model'; import { isFullScreen } from '../timeline/body/column_headers'; import { NewCase, ExistingCase } from '../timeline/properties/helpers'; -import { UNTITLED_TIMELINE } from '../timeline/properties/translations'; -import { - setInsertTimeline, - updateTimelineGraphEventId, -} from '../../../timelines/store/timeline/actions'; +import { updateTimelineGraphEventId } from '../../../timelines/store/timeline/actions'; import { Resolver } from '../../../resolver/view'; +import { useAllCasesModal } from '../../../cases/components/use_all_cases_modal'; import * as i18n from './translations'; @@ -112,35 +105,16 @@ const GraphOverlayComponent = ({ timelineType, }: OwnProps & PropsFromRedux) => { const dispatch = useDispatch(); - const { navigateToApp } = useKibana().services.application; const onCloseOverlay = useCallback(() => { dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: '' })); }, [dispatch, timelineId]); - const [showCaseModal, setShowCaseModal] = useState(false); - const onOpenCaseModal = useCallback(() => setShowCaseModal(true), []); - const onCloseCaseModal = useCallback(() => setShowCaseModal(false), [setShowCaseModal]); + const currentTimeline = useSelector((state: State) => timelineSelectors.selectTimeline(state, timelineId) ); - const onRowClick = useCallback( - (id?: string) => { - onCloseCaseModal(); - - navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { - path: id != null ? getCaseDetailsUrl({ id }) : getCreateCaseUrl(), - }).then(() => { - dispatch( - setInsertTimeline({ - graphEventId, - timelineId, - timelineSavedObjectId: currentTimeline.savedObjectId, - timelineTitle: title.length > 0 ? title : UNTITLED_TIMELINE, - }) - ); - }); - }, - [currentTimeline, dispatch, graphEventId, navigateToApp, onCloseCaseModal, timelineId, title] - ); + + const { Modal: AllCasesModal, onOpenModal: onOpenCaseModal } = useAllCasesModal({ timelineId }); + const { timelineFullScreen, setTimelineFullScreen, @@ -210,11 +184,7 @@ const GraphOverlayComponent = ({ databaseDocumentID={graphEventId} resolverComponentInstanceID={currentTimeline.id} /> - + ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx index 96a773507a30a..9eea95a0a9b1a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx @@ -6,7 +6,6 @@ import React, { useState, useCallback, useMemo } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import { TimelineStatusLiteral, TimelineTypeLiteral } from '../../../../../common/types/timeline'; import { useThrottledResizeObserver } from '../../../../common/components/utils'; import { Note } from '../../../../common/lib/note'; @@ -17,15 +16,7 @@ import { AssociateNote, UpdateNote } from '../../notes/helpers'; import { TimelineProperties } from './styles'; import { PropertiesRight } from './properties_right'; import { PropertiesLeft } from './properties_left'; -import { AllCasesModal } from '../../../../cases/components/all_cases_modal'; -import { SecurityPageName } from '../../../../app/types'; -import * as i18n from './translations'; -import { State } from '../../../../common/store'; -import { timelineSelectors } from '../../../store/timeline'; -import { setInsertTimeline } from '../../../store/timeline/actions'; -import { useKibana } from '../../../../common/lib/kibana'; -import { APP_ID } from '../../../../../common/constants'; -import { getCaseDetailsUrl, getCreateCaseUrl } from '../../../../common/components/link_to'; +import { useAllCasesModal } from '../../../../cases/components/use_all_cases_modal'; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; type UpdateTitle = ({ id, title }: { id: string; title: string }) => void; @@ -86,12 +77,10 @@ export const Properties = React.memo( updateTitle, usersViewing, }) => { - const { navigateToApp } = useKibana().services.application; const { ref, width = 0 } = useThrottledResizeObserver(300); const [showActions, setShowActions] = useState(false); const [showNotes, setShowNotes] = useState(false); const [showTimelineModal, setShowTimelineModal] = useState(false); - const dispatch = useDispatch(); const onButtonClick = useCallback(() => setShowActions(!showActions), [showActions]); const onToggleShowNotes = useCallback(() => setShowNotes(!showNotes), [showNotes]); @@ -103,32 +92,7 @@ export const Properties = React.memo( setShowTimelineModal(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const [showCaseModal, setShowCaseModal] = useState(false); - const onCloseCaseModal = useCallback(() => setShowCaseModal(false), []); - const onOpenCaseModal = useCallback(() => setShowCaseModal(true), []); - const currentTimeline = useSelector((state: State) => - timelineSelectors.selectTimeline(state, timelineId) - ); - - const onRowClick = useCallback( - (id?: string) => { - onCloseCaseModal(); - - navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { - path: id != null ? getCaseDetailsUrl({ id }) : getCreateCaseUrl(), - }).then(() => - dispatch( - setInsertTimeline({ - graphEventId, - timelineId, - timelineSavedObjectId: currentTimeline.savedObjectId, - timelineTitle: title.length > 0 ? title : i18n.UNTITLED_TIMELINE, - }) - ) - ); - }, - [currentTimeline, dispatch, graphEventId, navigateToApp, onCloseCaseModal, timelineId, title] - ); + const { Modal: AllCasesModal, onOpenModal: onOpenCaseModal } = useAllCasesModal({ timelineId }); const datePickerWidth = useMemo( () => @@ -195,11 +159,7 @@ export const Properties = React.memo( updateNote={updateNote} usersViewing={usersViewing} /> - + ); } From 2a77307af18ebce2422da9e9b2c91a0abdeb4ff3 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Mon, 27 Jul 2020 09:22:37 -0500 Subject: [PATCH 12/17] [APM] Use core.chrome to set window title (#73232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I noticed there's a `core.chrome.docTitle.change` method. It can take a string or array of strings and provides its own separator character if given an array. Replace our setting of `window.document.title` directly in the APM and Observability plugins with using the chrome method. This changes the title to be, for example, "トランザクション - opbeans-node - サービス - APM - Elastic" instead of "トランザクション | opbeans-node | サービス | APM | Elastic", using " - " as a separator instead of " | ". --- .../app/Main/UpdateBreadcrumbs.test.tsx | 55 +++++++++++-------- .../components/app/Main/UpdateBreadcrumbs.tsx | 9 ++- .../public/application/application.test.tsx | 29 ++++++++++ .../public/application/index.tsx | 7 +-- 4 files changed, 66 insertions(+), 34 deletions(-) create mode 100644 x-pack/plugins/observability/public/application/application.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx b/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx index 6aec6e9bf181a..2c19356a7fd52 100644 --- a/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx @@ -16,6 +16,7 @@ import { } from '../../../context/ApmPluginContext/MockApmPluginContext'; const setBreadcrumbs = jest.fn(); +const changeTitle = jest.fn(); function mountBreadcrumb(route: string, params = '') { mount( @@ -27,6 +28,7 @@ function mountBreadcrumb(route: string, params = '') { ...mockApmPluginContextValue.core, chrome: { ...mockApmPluginContextValue.core.chrome, + docTitle: { change: changeTitle }, setBreadcrumbs, }, }, @@ -42,23 +44,14 @@ function mountBreadcrumb(route: string, params = '') { } describe('UpdateBreadcrumbs', () => { - let realDoc: Document; - beforeEach(() => { - realDoc = window.document; - (window.document as any) = { - title: 'Kibana', - }; setBreadcrumbs.mockReset(); + changeTitle.mockReset(); }); - afterEach(() => { - (window.document as any) = realDoc; - }); - - it('Homepage', () => { + it('Changes the homepage title', () => { mountBreadcrumb('/'); - expect(window.document.title).toMatchInlineSnapshot(`"APM"`); + expect(changeTitle).toHaveBeenCalledWith(['APM']); }); it('/services/:serviceName/errors/:groupId', () => { @@ -90,9 +83,13 @@ describe('UpdateBreadcrumbs', () => { }, { text: 'myGroupId', href: undefined }, ]); - expect(window.document.title).toMatchInlineSnapshot( - `"myGroupId | Errors | opbeans-node | Services | APM"` - ); + expect(changeTitle).toHaveBeenCalledWith([ + 'myGroupId', + 'Errors', + 'opbeans-node', + 'Services', + 'APM', + ]); }); it('/services/:serviceName/errors', () => { @@ -104,9 +101,12 @@ describe('UpdateBreadcrumbs', () => { { text: 'opbeans-node', href: '#/services/opbeans-node?kuery=myKuery' }, { text: 'Errors', href: undefined }, ]); - expect(window.document.title).toMatchInlineSnapshot( - `"Errors | opbeans-node | Services | APM"` - ); + expect(changeTitle).toHaveBeenCalledWith([ + 'Errors', + 'opbeans-node', + 'Services', + 'APM', + ]); }); it('/services/:serviceName/transactions', () => { @@ -118,9 +118,12 @@ describe('UpdateBreadcrumbs', () => { { text: 'opbeans-node', href: '#/services/opbeans-node?kuery=myKuery' }, { text: 'Transactions', href: undefined }, ]); - expect(window.document.title).toMatchInlineSnapshot( - `"Transactions | opbeans-node | Services | APM"` - ); + expect(changeTitle).toHaveBeenCalledWith([ + 'Transactions', + 'opbeans-node', + 'Services', + 'APM', + ]); }); it('/services/:serviceName/transactions/view?transactionName=my-transaction-name', () => { @@ -139,8 +142,12 @@ describe('UpdateBreadcrumbs', () => { }, { text: 'my-transaction-name', href: undefined }, ]); - expect(window.document.title).toMatchInlineSnapshot( - `"my-transaction-name | Transactions | opbeans-node | Services | APM"` - ); + expect(changeTitle).toHaveBeenCalledWith([ + 'my-transaction-name', + 'Transactions', + 'opbeans-node', + 'Services', + 'APM', + ]); }); }); diff --git a/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx b/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx index 7a27eae6e89f7..e7657c63f41bb 100644 --- a/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx +++ b/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx @@ -22,10 +22,7 @@ interface Props { } function getTitleFromBreadCrumbs(breadcrumbs: Breadcrumb[]) { - return breadcrumbs - .map(({ value }) => value) - .reverse() - .join(' | '); + return breadcrumbs.map(({ value }) => value).reverse(); } class UpdateBreadcrumbsComponent extends React.Component { @@ -43,7 +40,9 @@ class UpdateBreadcrumbsComponent extends React.Component { } ); - document.title = getTitleFromBreadCrumbs(this.props.breadcrumbs); + this.props.core.chrome.docTitle.change( + getTitleFromBreadCrumbs(this.props.breadcrumbs) + ); this.props.core.chrome.setBreadcrumbs(breadcrumbs); } diff --git a/x-pack/plugins/observability/public/application/application.test.tsx b/x-pack/plugins/observability/public/application/application.test.tsx new file mode 100644 index 0000000000000..db7fca140be89 --- /dev/null +++ b/x-pack/plugins/observability/public/application/application.test.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { renderApp } from './'; +import { Observable } from 'rxjs'; +import { CoreStart, AppMountParameters } from 'src/core/public'; + +describe('renderApp', () => { + it('renders', () => { + const core = ({ + application: { currentAppId$: new Observable(), navigateToUrl: () => {} }, + chrome: { docTitle: { change: () => {} }, setBreadcrumbs: () => {} }, + i18n: { Context: ({ children }: { children: React.ReactNode }) => children }, + uiSettings: { get: () => false }, + } as unknown) as CoreStart; + const params = ({ + element: window.document.createElement('div'), + } as unknown) as AppMountParameters; + + expect(() => { + const unmount = renderApp(core, params); + unmount(); + }).not.toThrowError(); + }); +}); diff --git a/x-pack/plugins/observability/public/application/index.tsx b/x-pack/plugins/observability/public/application/index.tsx index b0134ed8b746b..4c0147dc3cd51 100644 --- a/x-pack/plugins/observability/public/application/index.tsx +++ b/x-pack/plugins/observability/public/application/index.tsx @@ -23,10 +23,7 @@ const observabilityLabelBreadcrumb = { }; function getTitleFromBreadCrumbs(breadcrumbs: Breadcrumbs) { - return breadcrumbs - .map(({ text }) => text) - .reverse() - .join(' | '); + return breadcrumbs.map(({ text }) => text).reverse(); } function App() { @@ -42,7 +39,7 @@ function App() { const breadcrumb = [observabilityLabelBreadcrumb, ...route.breadcrumb]; useEffect(() => { core.chrome.setBreadcrumbs(breadcrumb); - document.title = getTitleFromBreadCrumbs(breadcrumb); + core.chrome.docTitle.change(getTitleFromBreadCrumbs(breadcrumb)); }, [core, breadcrumb]); const { query, path: pathParams } = useRouteParams(route.params); From aa45ac89b07be9ccaffdc05afb890de277ead4a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Mon, 27 Jul 2020 16:36:25 +0200 Subject: [PATCH 13/17] [Logs UI] Return empty result sets instead of 500 or 404 for analysis results (#72824) This changes the analysis results routes to return empty result sets with HTTP status code 200 instead of and inconsistent status codes 500 or 404. --- .../infra/server/lib/log_analysis/common.ts | 13 +--- .../infra/server/lib/log_analysis/errors.ts | 7 -- .../log_entry_categories_analysis.ts | 65 ++++++++----------- .../log_analysis/log_entry_rate_analysis.ts | 22 ++----- .../queries/log_entry_data_sets.ts | 2 +- .../log_analysis/queries/log_entry_rate.ts | 2 +- .../queries/top_log_entry_categories.ts | 2 +- .../results/log_entry_anomalies_datasets.ts | 9 +-- .../results/log_entry_categories.ts | 9 +-- .../results/log_entry_category_datasets.ts | 9 +-- .../results/log_entry_category_examples.ts | 9 +-- .../results/log_entry_examples.ts | 6 +- .../log_analysis/results/log_entry_rate.ts | 6 +- 13 files changed, 44 insertions(+), 117 deletions(-) diff --git a/x-pack/plugins/infra/server/lib/log_analysis/common.ts b/x-pack/plugins/infra/server/lib/log_analysis/common.ts index 218281d875a46..4d2be94c7cd62 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/common.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/common.ts @@ -14,7 +14,6 @@ import { logEntryDatasetsResponseRT, } from './queries/log_entry_data_sets'; import { decodeOrThrow } from '../../../common/runtime_types'; -import { NoLogAnalysisResultsIndexError } from './errors'; import { startTracingSpan, TracingSpan } from '../../../common/performance_tracing'; export async function fetchMlJob(mlAnomalyDetectors: MlAnomalyDetectors, jobId: string) { @@ -67,16 +66,8 @@ export async function getLogEntryDatasets( ) ); - if (logEntryDatasetsResponse._shards.total === 0) { - throw new NoLogAnalysisResultsIndexError( - `Failed to find ml indices for jobs: ${jobIds.join(', ')}.` - ); - } - - const { - after_key: afterKey, - buckets: latestBatchBuckets, - } = logEntryDatasetsResponse.aggregations.dataset_buckets; + const { after_key: afterKey, buckets: latestBatchBuckets = [] } = + logEntryDatasetsResponse.aggregations?.dataset_buckets ?? {}; logEntryDatasetBuckets = [...logEntryDatasetBuckets, ...latestBatchBuckets]; afterLatestBatchKey = afterKey; diff --git a/x-pack/plugins/infra/server/lib/log_analysis/errors.ts b/x-pack/plugins/infra/server/lib/log_analysis/errors.ts index 09fee8844fbc5..a6d0db25084e8 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/errors.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/errors.ts @@ -6,13 +6,6 @@ /* eslint-disable max-classes-per-file */ -export class NoLogAnalysisResultsIndexError extends Error { - constructor(message?: string) { - super(message); - Object.setPrototypeOf(this, new.target.prototype); - } -} - export class NoLogAnalysisMlJobError extends Error { constructor(message?: string) { super(message); diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts index a455a03d936a5..ff9e3c7d2167c 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts @@ -15,11 +15,7 @@ import { import { startTracingSpan } from '../../../common/performance_tracing'; import { decodeOrThrow } from '../../../common/runtime_types'; import type { MlAnomalyDetectors, MlSystem } from '../../types'; -import { - InsufficientLogAnalysisMlJobConfigurationError, - NoLogAnalysisResultsIndexError, - UnknownCategoryError, -} from './errors'; +import { InsufficientLogAnalysisMlJobConfigurationError, UnknownCategoryError } from './errors'; import { createLogEntryCategoriesQuery, logEntryCategoriesResponseRT, @@ -235,38 +231,33 @@ async function fetchTopLogEntryCategories( const esSearchSpan = finalizeEsSearchSpan(); - if (topLogEntryCategoriesResponse._shards.total === 0) { - throw new NoLogAnalysisResultsIndexError( - `Failed to find ml result index for job ${logEntryCategoriesCountJobId}.` - ); - } - - const topLogEntryCategories = topLogEntryCategoriesResponse.aggregations.terms_category_id.buckets.map( - (topCategoryBucket) => { - const maximumAnomalyScoresByDataset = topCategoryBucket.filter_record.terms_dataset.buckets.reduce< - Record - >( - (accumulatedMaximumAnomalyScores, datasetFromRecord) => ({ - ...accumulatedMaximumAnomalyScores, - [datasetFromRecord.key]: datasetFromRecord.maximum_record_score.value ?? 0, - }), - {} - ); - - return { - categoryId: parseCategoryId(topCategoryBucket.key), - logEntryCount: topCategoryBucket.filter_model_plot.sum_actual.value ?? 0, - datasets: topCategoryBucket.filter_model_plot.terms_dataset.buckets - .map((datasetBucket) => ({ - name: datasetBucket.key, - maximumAnomalyScore: maximumAnomalyScoresByDataset[datasetBucket.key] ?? 0, - })) - .sort(compareDatasetsByMaximumAnomalyScore) - .reverse(), - maximumAnomalyScore: topCategoryBucket.filter_record.maximum_record_score.value ?? 0, - }; - } - ); + const topLogEntryCategories = + topLogEntryCategoriesResponse.aggregations?.terms_category_id.buckets.map( + (topCategoryBucket) => { + const maximumAnomalyScoresByDataset = topCategoryBucket.filter_record.terms_dataset.buckets.reduce< + Record + >( + (accumulatedMaximumAnomalyScores, datasetFromRecord) => ({ + ...accumulatedMaximumAnomalyScores, + [datasetFromRecord.key]: datasetFromRecord.maximum_record_score.value ?? 0, + }), + {} + ); + + return { + categoryId: parseCategoryId(topCategoryBucket.key), + logEntryCount: topCategoryBucket.filter_model_plot.sum_actual.value ?? 0, + datasets: topCategoryBucket.filter_model_plot.terms_dataset.buckets + .map((datasetBucket) => ({ + name: datasetBucket.key, + maximumAnomalyScore: maximumAnomalyScoresByDataset[datasetBucket.key] ?? 0, + })) + .sort(compareDatasetsByMaximumAnomalyScore) + .reverse(), + maximumAnomalyScore: topCategoryBucket.filter_record.maximum_record_score.value ?? 0, + }; + } + ) ?? []; return { topLogEntryCategories, diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts index 7bfc85ba78a0e..ce3acd0dba8cf 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts @@ -4,10 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pipe } from 'fp-ts/lib/pipeable'; -import { map, fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; -import { throwErrors, createPlainError } from '../../../common/runtime_types'; +import { decodeOrThrow } from '../../../common/runtime_types'; import { logRateModelPlotResponseRT, createLogEntryRateQuery, @@ -15,7 +12,6 @@ import { CompositeTimestampPartitionKey, } from './queries'; import { getJobId } from '../../../common/log_analysis'; -import { NoLogAnalysisResultsIndexError } from './errors'; import type { MlSystem } from '../../types'; const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; @@ -50,22 +46,14 @@ export async function getLogEntryRateBuckets( ) ); - if (mlModelPlotResponse._shards.total === 0) { - throw new NoLogAnalysisResultsIndexError( - `Failed to query ml result index for job ${logRateJobId}.` - ); - } - - const { after_key: afterKey, buckets: latestBatchBuckets } = pipe( - logRateModelPlotResponseRT.decode(mlModelPlotResponse), - map((response) => response.aggregations.timestamp_partition_buckets), - fold(throwErrors(createPlainError), identity) - ); + const { after_key: afterKey, buckets: latestBatchBuckets = [] } = + decodeOrThrow(logRateModelPlotResponseRT)(mlModelPlotResponse).aggregations + ?.timestamp_partition_buckets ?? {}; mlModelPlotBuckets = [...mlModelPlotBuckets, ...latestBatchBuckets]; afterLatestBatchKey = afterKey; - if (latestBatchBuckets.length < COMPOSITE_AGGREGATION_BATCH_SIZE) { + if (afterKey == null || latestBatchBuckets.length < COMPOSITE_AGGREGATION_BATCH_SIZE) { break; } } diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts index 7627ccd8c4996..53971a91d86b1 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts @@ -67,7 +67,7 @@ export type LogEntryDatasetBucket = rt.TypeOf; export const logEntryDatasetsResponseRT = rt.intersection([ commonSearchSuccessResponseFieldsRT, - rt.type({ + rt.partial({ aggregations: rt.type({ dataset_buckets: rt.intersection([ rt.type({ diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts index 52edcf09cdfc2..e82dd8ef4443c 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts @@ -162,7 +162,7 @@ export const logRateModelPlotBucketRT = rt.type({ export type LogRateModelPlotBucket = rt.TypeOf; -export const logRateModelPlotResponseRT = rt.type({ +export const logRateModelPlotResponseRT = rt.partial({ aggregations: rt.type({ timestamp_partition_buckets: rt.intersection([ rt.type({ diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts index 355dde9ec7c4a..5d3d9bc8b4036 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts @@ -159,7 +159,7 @@ export type LogEntryCategoryBucket = rt.TypeOf; export const topLogEntryCategoriesResponseRT = rt.intersection([ commonSearchSuccessResponseFieldsRT, - rt.type({ + rt.partial({ aggregations: rt.type({ terms_category_id: rt.type({ buckets: rt.array(logEntryCategoryBucketRT), diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies_datasets.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies_datasets.ts index d3d0862eee9aa..f1f1a1681a901 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies_datasets.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies_datasets.ts @@ -12,10 +12,7 @@ import { } from '../../../../common/http_api/log_analysis'; import { createValidationFunction } from '../../../../common/runtime_types'; import type { InfraBackendLibs } from '../../../lib/infra_types'; -import { - getLogEntryAnomaliesDatasets, - NoLogAnalysisResultsIndexError, -} from '../../../lib/log_analysis'; +import { getLogEntryAnomaliesDatasets } from '../../../lib/log_analysis'; import { assertHasInfraMlPlugins } from '../../../utils/request_context'; export const initGetLogEntryAnomaliesDatasetsRoute = ({ framework }: InfraBackendLibs) => { @@ -58,10 +55,6 @@ export const initGetLogEntryAnomaliesDatasetsRoute = ({ framework }: InfraBacken throw error; } - if (error instanceof NoLogAnalysisResultsIndexError) { - return response.notFound({ body: { message: error.message } }); - } - return response.customError({ statusCode: error.statusCode ?? 500, body: { diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts index f9f31f28dffeb..f57132ef1b505 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts @@ -12,10 +12,7 @@ import { } from '../../../../common/http_api/log_analysis'; import { createValidationFunction } from '../../../../common/runtime_types'; import type { InfraBackendLibs } from '../../../lib/infra_types'; -import { - getTopLogEntryCategories, - NoLogAnalysisResultsIndexError, -} from '../../../lib/log_analysis'; +import { getTopLogEntryCategories } from '../../../lib/log_analysis'; import { assertHasInfraMlPlugins } from '../../../utils/request_context'; export const initGetLogEntryCategoriesRoute = ({ framework }: InfraBackendLibs) => { @@ -69,10 +66,6 @@ export const initGetLogEntryCategoriesRoute = ({ framework }: InfraBackendLibs) throw error; } - if (error instanceof NoLogAnalysisResultsIndexError) { - return response.notFound({ body: { message: error.message } }); - } - return response.customError({ statusCode: error.statusCode ?? 500, body: { diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts index 69b1e942464fd..b99ff920f81e4 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts @@ -12,10 +12,7 @@ import { } from '../../../../common/http_api/log_analysis'; import { createValidationFunction } from '../../../../common/runtime_types'; import type { InfraBackendLibs } from '../../../lib/infra_types'; -import { - getLogEntryCategoryDatasets, - NoLogAnalysisResultsIndexError, -} from '../../../lib/log_analysis'; +import { getLogEntryCategoryDatasets } from '../../../lib/log_analysis'; import { assertHasInfraMlPlugins } from '../../../utils/request_context'; export const initGetLogEntryCategoryDatasetsRoute = ({ framework }: InfraBackendLibs) => { @@ -58,10 +55,6 @@ export const initGetLogEntryCategoryDatasetsRoute = ({ framework }: InfraBackend throw error; } - if (error instanceof NoLogAnalysisResultsIndexError) { - return response.notFound({ body: { message: error.message } }); - } - return response.customError({ statusCode: error.statusCode ?? 500, body: { diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts index 8baeaac3d1699..11098ebe5c65b 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts @@ -12,10 +12,7 @@ import { } from '../../../../common/http_api/log_analysis'; import { createValidationFunction } from '../../../../common/runtime_types'; import type { InfraBackendLibs } from '../../../lib/infra_types'; -import { - getLogEntryCategoryExamples, - NoLogAnalysisResultsIndexError, -} from '../../../lib/log_analysis'; +import { getLogEntryCategoryExamples } from '../../../lib/log_analysis'; import { assertHasInfraMlPlugins } from '../../../utils/request_context'; export const initGetLogEntryCategoryExamplesRoute = ({ framework, sources }: InfraBackendLibs) => { @@ -68,10 +65,6 @@ export const initGetLogEntryCategoryExamplesRoute = ({ framework, sources }: Inf throw error; } - if (error instanceof NoLogAnalysisResultsIndexError) { - return response.notFound({ body: { message: error.message } }); - } - return response.customError({ statusCode: error.statusCode ?? 500, body: { diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts index be4caee769506..7838a64a6045e 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts @@ -7,7 +7,7 @@ import Boom from 'boom'; import { createValidationFunction } from '../../../../common/runtime_types'; import { InfraBackendLibs } from '../../../lib/infra_types'; -import { NoLogAnalysisResultsIndexError, getLogEntryExamples } from '../../../lib/log_analysis'; +import { getLogEntryExamples } from '../../../lib/log_analysis'; import { assertHasInfraMlPlugins } from '../../../utils/request_context'; import { getLogEntryExamplesRequestPayloadRT, @@ -68,10 +68,6 @@ export const initGetLogEntryExamplesRoute = ({ framework, sources }: InfraBacken throw error; } - if (error instanceof NoLogAnalysisResultsIndexError) { - return response.notFound({ body: { message: error.message } }); - } - return response.customError({ statusCode: error.statusCode ?? 500, body: { diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts index 3b05f6ed23aae..cd23c0193e291 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts @@ -13,7 +13,7 @@ import { GetLogEntryRateSuccessResponsePayload, } from '../../../../common/http_api/log_analysis'; import { createValidationFunction } from '../../../../common/runtime_types'; -import { NoLogAnalysisResultsIndexError, getLogEntryRateBuckets } from '../../../lib/log_analysis'; +import { getLogEntryRateBuckets } from '../../../lib/log_analysis'; import { assertHasInfraMlPlugins } from '../../../utils/request_context'; export const initGetLogEntryRateRoute = ({ framework }: InfraBackendLibs) => { @@ -56,10 +56,6 @@ export const initGetLogEntryRateRoute = ({ framework }: InfraBackendLibs) => { throw error; } - if (error instanceof NoLogAnalysisResultsIndexError) { - return response.notFound({ body: { message: error.message } }); - } - return response.customError({ statusCode: error.statusCode ?? 500, body: { From 02e3fca77258b166b20dbbf2dc280e146b59ec27 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Mon, 27 Jul 2020 16:01:03 +0100 Subject: [PATCH 14/17] fix icon type (#73254) --- .../components/timeline/header/index.test.tsx | 95 +++++++++++++++++++ .../components/timeline/header/index.tsx | 2 +- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx index 58c213dc884ea..e0043f3b232da 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx @@ -85,5 +85,100 @@ describe('Header', () => { expect(wrapper.find('[data-test-subj="timelineCallOutUnauthorized"]').exists()).toEqual(true); }); + + test('it renders the unauthorized call out with correct icon', () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: true, + }; + + const wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="timelineCallOutUnauthorized"]').first().prop('iconType') + ).toEqual('alert'); + }); + + test('it renders the unauthorized call out with correct message', () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: true, + }; + + const wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="timelineCallOutUnauthorized"]').first().prop('title') + ).toEqual( + 'You can use Timeline to investigate events, but you do not have the required permissions to save timelines for future use. If you need to save timelines, contact your Kibana administrator.' + ); + }); + + test('it renders the immutable timeline call out providers', () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: false, + status: TimelineStatus.immutable, + }; + + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="timelineImmutableCallOut"]').exists()).toEqual(true); + }); + + test('it renders the immutable timeline call out with correct icon', () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: false, + status: TimelineStatus.immutable, + }; + + const wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="timelineImmutableCallOut"]').first().prop('iconType') + ).toEqual('alert'); + }); + + test('it renders the immutable timeline call out with correct message', () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: false, + status: TimelineStatus.immutable, + }; + + const wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="timelineImmutableCallOut"]').first().prop('title') + ).toEqual( + 'This timeline is immutable, therefore not allowed to save it within the security application, though you may continue to use the timeline to search and filter security events' + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx index aa3ce88acc200..75bfb52f2756b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx @@ -75,7 +75,7 @@ const TimelineHeaderComponent: React.FC = ({ data-test-subj="timelineImmutableCallOut" title={i18n.CALL_OUT_IMMUTIABLE} color="primary" - iconType="info" + iconType="alert" size="s" /> )} From 9aa5e1772da99fb90f248e9991591fda27160edc Mon Sep 17 00:00:00 2001 From: igoristic Date: Mon, 27 Jul 2020 11:10:25 -0400 Subject: [PATCH 15/17] [Monitoring] "Internal Monitoring" deprecation warning (#72020) * Internal Monitoring deprecation * Fixed type * Added if cloud logic * Fixed i18n test * Addressed code review feedback * Fixed types * Changed query * Added delay to fix a test * Fixed tests * Fixed text Co-authored-by: Elastic Machine --- .../public/lib/internal_monitoring_toasts.tsx | 123 ++++++++++++++++++ .../monitoring/public/services/clusters.js | 34 ++++- .../check/internal_monitoring.ts | 85 ++++++++++++ .../api/v1/elasticsearch_settings/index.js | 1 + .../monitoring/server/routes/api/v1/ui.js | 1 + .../functional/apps/monitoring/time_filter.js | 7 + 6 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 x-pack/plugins/monitoring/public/lib/internal_monitoring_toasts.tsx create mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts diff --git a/x-pack/plugins/monitoring/public/lib/internal_monitoring_toasts.tsx b/x-pack/plugins/monitoring/public/lib/internal_monitoring_toasts.tsx new file mode 100644 index 0000000000000..b6ecb631d005a --- /dev/null +++ b/x-pack/plugins/monitoring/public/lib/internal_monitoring_toasts.tsx @@ -0,0 +1,123 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSpacer, EuiLink } from '@elastic/eui'; +import { Legacy } from '../legacy_shims'; +import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; +import { isInSetupMode, toggleSetupMode } from './setup_mode'; + +export interface MonitoringIndicesTypes { + legacyIndices: number; + metricbeatIndices: number; +} + +const enterSetupModeLabel = () => + i18n.translate('xpack.monitoring.internalMonitoringToast.enterSetupMode', { + defaultMessage: 'Enter setup mode', + }); + +const learnMoreLabel = () => + i18n.translate('xpack.monitoring.internalMonitoringToast.learnMoreAction', { + defaultMessage: 'Learn more', + }); + +const showIfLegacyOnlyIndices = () => { + const { ELASTIC_WEBSITE_URL } = Legacy.shims.docLinks; + const toast = Legacy.shims.toastNotifications.addWarning({ + title: toMountPoint( + + ), + text: toMountPoint( +
+

+ {i18n.translate('xpack.monitoring.internalMonitoringToast.description', { + defaultMessage: `It appears you are using "Legacy Collection" for Stack Monitoring. + This method of monitoring will no longer be supported in the next major release (8.0.0). + Please follow the steps in setup mode to start monitoring with Metricbeat.`, + })} +

+ { + Legacy.shims.toastNotifications.remove(toast); + toggleSetupMode(true); + }} + > + {enterSetupModeLabel()} + + + + + {learnMoreLabel()} + +
+ ), + }); +}; + +const showIfLegacyAndMetricbeatIndices = () => { + const { ELASTIC_WEBSITE_URL } = Legacy.shims.docLinks; + const toast = Legacy.shims.toastNotifications.addWarning({ + title: toMountPoint( + + ), + text: toMountPoint( +
+

+ {i18n.translate('xpack.monitoring.internalAndMetricbeatMonitoringToast.description', { + defaultMessage: `It appears you are using both Metricbeat and "Legacy Collection" for Stack Monitoring. + In 8.0.0, you must use Metricbeat to collect monitoring data. + Please follow the steps in setup mode to migrate the rest of the monitoring to Metricbeat.`, + })} +

+ { + Legacy.shims.toastNotifications.remove(toast); + toggleSetupMode(true); + }} + > + {enterSetupModeLabel()} + + + + + {learnMoreLabel()} + +
+ ), + }); +}; + +export const showInternalMonitoringToast = ({ + legacyIndices, + metricbeatIndices, +}: MonitoringIndicesTypes) => { + if (isInSetupMode()) { + return; + } + + if (legacyIndices && !metricbeatIndices) { + showIfLegacyOnlyIndices(); + } else if (legacyIndices && metricbeatIndices) { + showIfLegacyAndMetricbeatIndices(); + } +}; diff --git a/x-pack/plugins/monitoring/public/services/clusters.js b/x-pack/plugins/monitoring/public/services/clusters.js index 5173984dbe868..7f772ac1e1bcd 100644 --- a/x-pack/plugins/monitoring/public/services/clusters.js +++ b/x-pack/plugins/monitoring/public/services/clusters.js @@ -7,6 +7,7 @@ import { ajaxErrorHandlersProvider } from '../lib/ajax_error_handler'; import { Legacy } from '../legacy_shims'; import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../common/constants'; +import { showInternalMonitoringToast } from '../lib/internal_monitoring_toasts'; import { showSecurityToast } from '../alerts/lib/security_toasts'; function formatClusters(clusters) { @@ -21,6 +22,7 @@ function formatCluster(cluster) { } let once = false; +let inTransit = false; export function monitoringClustersProvider($injector) { return (clusterUuid, ccs, codePaths) => { @@ -63,19 +65,39 @@ export function monitoringClustersProvider($injector) { }); } - if (!once) { + function ensureMetricbeatEnabled() { + if (Legacy.shims.isCloud) { + return Promise.resolve(); + } + + return $http + .get('../api/monitoring/v1/elasticsearch_settings/check/internal_monitoring') + .then(({ data }) => { + showInternalMonitoringToast({ + legacyIndices: data.legacy_indices, + metricbeatIndices: data.mb_indices, + }); + }) + .catch((err) => { + const Private = $injector.get('Private'); + const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); + return ajaxErrorHandlers(err); + }); + } + + if (!once && !inTransit) { + inTransit = true; return getClusters().then((clusters) => { if (clusters.length) { - return ensureAlertsEnabled() - .then(({ data }) => { + Promise.all([ensureAlertsEnabled(), ensureMetricbeatEnabled()]) + .then(([{ data }]) => { showSecurityToast(data); once = true; - return clusters; }) .catch(() => { // Intentionally swallow the error as this will retry the next page load - return clusters; - }); + }) + .finally(() => (inTransit = false)); } return clusters; }); diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts new file mode 100644 index 0000000000000..4473d824c9e30 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandlerContext } from 'kibana/server'; +// @ts-ignore +import { getIndexPatterns } from '../../../../../lib/cluster/get_index_patterns'; +// @ts-ignore +import { handleError } from '../../../../../lib/errors'; +import { RouteDependencies } from '../../../../../types'; + +const queryBody = { + size: 0, + aggs: { + types: { + terms: { + field: '_index', + size: 10, + }, + }, + }, +}; + +const checkLatestMonitoringIsLegacy = async (context: RequestHandlerContext, index: string) => { + const { client: esClient } = context.core.elasticsearch.legacy; + const result = await esClient.callAsCurrentUser('search', { + index, + body: queryBody, + }); + + const { aggregations } = result; + const counts = { + legacyIndicesCount: 0, + mbIndicesCount: 0, + }; + + if (!aggregations) { + return counts; + } + + const { + types: { buckets }, + } = aggregations; + counts.mbIndicesCount = buckets.filter(({ key }: { key: string }) => key.includes('-mb-')).length; + + counts.legacyIndicesCount = buckets.length - counts.mbIndicesCount; + return counts; +}; + +export function internalMonitoringCheckRoute(server: unknown, npRoute: RouteDependencies) { + npRoute.router.get( + { + path: '/api/monitoring/v1/elasticsearch_settings/check/internal_monitoring', + validate: false, + }, + async (context, _request, response) => { + try { + const typeCount = { + legacy_indices: 0, + mb_indices: 0, + }; + + const { esIndexPattern, kbnIndexPattern, lsIndexPattern } = getIndexPatterns(server); + const indexCounts = await Promise.all([ + checkLatestMonitoringIsLegacy(context, esIndexPattern), + checkLatestMonitoringIsLegacy(context, kbnIndexPattern), + checkLatestMonitoringIsLegacy(context, lsIndexPattern), + ]); + + indexCounts.forEach((counts) => { + typeCount.legacy_indices += counts.legacyIndicesCount; + typeCount.mb_indices += counts.mbIndicesCount; + }); + + return response.ok({ + body: typeCount, + }); + } catch (err) { + throw handleError(err); + } + } + ); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.js b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.js index d7ef71efc0b51..906057d221868 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.js @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { internalMonitoringCheckRoute } from './check/internal_monitoring'; export { clusterSettingsCheckRoute } from './check/cluster'; export { nodesSettingsCheckRoute } from './check/nodes'; export { setCollectionEnabledRoute } from './set/collection_enabled'; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/ui.js b/x-pack/plugins/monitoring/server/routes/api/v1/ui.js index de0213ec84689..e8daf52582437 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/ui.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/ui.js @@ -20,6 +20,7 @@ export { ccrShardRoute, } from './elasticsearch'; export { + internalMonitoringCheckRoute, clusterSettingsCheckRoute, nodesSettingsCheckRoute, setCollectionEnabledRoute, diff --git a/x-pack/test/functional/apps/monitoring/time_filter.js b/x-pack/test/functional/apps/monitoring/time_filter.js index d7ffdb4a7900d..11557d995218e 100644 --- a/x-pack/test/functional/apps/monitoring/time_filter.js +++ b/x-pack/test/functional/apps/monitoring/time_filter.js @@ -7,6 +7,8 @@ import expect from '@kbn/expect'; import { getLifecycleMethods } from './_get_lifecycle_methods'; +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['header', 'timePicker']); const testSubjects = getService('testSubjects'); @@ -35,6 +37,11 @@ export default function ({ getService, getPageObjects }) { }); it('should send another request when changing the time picker', async () => { + /** + * TODO: The value should either be removed or lowered after: + * https://github.com/elastic/kibana/issues/72997 is resolved + */ + await delay(3000); await PageObjects.timePicker.setAbsoluteRange( 'Aug 15, 2016 @ 21:00:00.000', 'Aug 16, 2016 @ 00:00:00.000' From 6d4bb9dc0d5bf8bbcdff17f73b4c77b0f1ccea35 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Mon, 27 Jul 2020 11:13:26 -0400 Subject: [PATCH 16/17] [SECURITY_SOLUTION][ENDPOINT] Handle Host list/details policy links to non-existing policies (#73208) * Make API call to check policies and save it to store * change policy list and details to not show policy as a link if it does not exist --- .../pages/endpoint_hosts/store/action.ts | 9 +- .../pages/endpoint_hosts/store/index.test.ts | 1 + .../pages/endpoint_hosts/store/middleware.ts | 115 +++++++++++++++++- .../pages/endpoint_hosts/store/reducer.ts | 9 ++ .../pages/endpoint_hosts/store/selectors.ts | 8 ++ .../management/pages/endpoint_hosts/types.ts | 2 + .../view/components/host_policy_link.tsx | 53 ++++++++ .../view/details/host_details.tsx | 29 +---- .../pages/endpoint_hosts/view/index.tsx | 18 +-- .../store/policy_list/services/ingest.ts | 15 +++ 10 files changed, 224 insertions(+), 35 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/host_policy_link.tsx diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts index 4c01b3644cf63..621fab2e4ee11 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts @@ -12,6 +12,7 @@ import { import { ServerApiError } from '../../../../common/types'; import { GetPolicyListResponse } from '../../policy/types'; import { GetPackagesResponse } from '../../../../../../ingest_manager/common'; +import { HostState } from '../types'; interface ServerReturnedHostList { type: 'serverReturnedHostList'; @@ -75,6 +76,11 @@ interface ServerReturnedEndpointPackageInfo { payload: GetPackagesResponse['response'][0]; } +interface ServerReturnedHostNonExistingPolicies { + type: 'serverReturnedHostNonExistingPolicies'; + payload: HostState['nonExistingPolicies']; +} + export type HostAction = | ServerReturnedHostList | ServerFailedToReturnHostList @@ -87,4 +93,5 @@ export type HostAction = | UserSelectedEndpointPolicy | ServerCancelledHostListLoading | ServerCancelledPolicyItemsLoading - | ServerReturnedEndpointPackageInfo; + | ServerReturnedEndpointPackageInfo + | ServerReturnedHostNonExistingPolicies; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts index f2c205661b32c..b6e18506b6111 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts @@ -50,6 +50,7 @@ describe('HostList store concerns', () => { selectedPolicyId: undefined, policyItemsLoading: false, endpointPackageInfo: undefined, + nonExistingPolicies: {}, }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 12fa3dc47beac..edeca5659ee38 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { HostResultList } from '../../../../../common/endpoint/types'; +import { HttpSetup } from 'kibana/public'; +import { HostInfo, HostResultList } from '../../../../../common/endpoint/types'; import { GetPolicyListResponse } from '../../policy/types'; import { ImmutableMiddlewareFactory } from '../../../../common/store'; import { @@ -13,12 +14,15 @@ import { uiQueryParams, listData, endpointPackageInfo, + nonExistingPolicies, } from './selectors'; import { HostState } from '../types'; import { sendGetEndpointSpecificPackageConfigs, sendGetEndpointSecurityPackage, + sendGetAgentConfigList, } from '../../policy/store/policy_list/services/ingest'; +import { AGENT_CONFIG_SAVED_OBJECT_TYPE } from '../../../../../../ingest_manager/common'; export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (coreStart) => { return ({ getState, dispatch }) => (next) => async (action) => { @@ -58,6 +62,23 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (cor type: 'serverReturnedHostList', payload: hostResponse, }); + + getNonExistingPoliciesForHostsList( + coreStart.http, + hostResponse.hosts, + nonExistingPolicies(state) + ) + .then((missingPolicies) => { + if (missingPolicies !== undefined) { + dispatch({ + type: 'serverReturnedHostNonExistingPolicies', + payload: missingPolicies, + }); + } + }) + // Ignore Errors, since this should not hinder the user's ability to use the UI + // eslint-disable-next-line no-console + .catch((error) => console.error(error)); } catch (error) { dispatch({ type: 'serverFailedToReturnHostList', @@ -117,6 +138,23 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (cor type: 'serverReturnedHostList', payload: response, }); + + getNonExistingPoliciesForHostsList( + coreStart.http, + response.hosts, + nonExistingPolicies(state) + ) + .then((missingPolicies) => { + if (missingPolicies !== undefined) { + dispatch({ + type: 'serverReturnedHostNonExistingPolicies', + payload: missingPolicies, + }); + } + }) + // Ignore Errors, since this should not hinder the user's ability to use the UI + // eslint-disable-next-line no-console + .catch((error) => console.error(error)); } catch (error) { dispatch({ type: 'serverFailedToReturnHostList', @@ -133,11 +171,25 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (cor // call the host details api const { selected_host: selectedHost } = uiQueryParams(state); try { - const response = await coreStart.http.get(`/api/endpoint/metadata/${selectedHost}`); + const response = await coreStart.http.get( + `/api/endpoint/metadata/${selectedHost}` + ); dispatch({ type: 'serverReturnedHostDetails', payload: response, }); + getNonExistingPoliciesForHostsList(coreStart.http, [response], nonExistingPolicies(state)) + .then((missingPolicies) => { + if (missingPolicies !== undefined) { + dispatch({ + type: 'serverReturnedHostNonExistingPolicies', + payload: missingPolicies, + }); + } + }) + // Ignore Errors, since this should not hinder the user's ability to use the UI + // eslint-disable-next-line no-console + .catch((error) => console.error(error)); } catch (error) { dispatch({ type: 'serverFailedToReturnHostDetails', @@ -163,3 +215,62 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (cor } }; }; + +const getNonExistingPoliciesForHostsList = async ( + http: HttpSetup, + hosts: HostResultList['hosts'], + currentNonExistingPolicies: HostState['nonExistingPolicies'] +): Promise => { + if (hosts.length === 0) { + return; + } + + // Create an array of unique policy IDs that are not yet known to be non-existing. + const policyIdsToCheck = Array.from( + new Set( + hosts + .filter((host) => !currentNonExistingPolicies[host.metadata.Endpoint.policy.applied.id]) + .map((host) => host.metadata.Endpoint.policy.applied.id) + ) + ); + + if (policyIdsToCheck.length === 0) { + return; + } + + // We use the Agent Config API here, instead of the Package Config, because we can't use + // filter by ID of the Saved Object. Agent Config, however, keeps a reference (array) of + // Package Ids that it uses, thus if a reference exists there, then the package config (policy) + // exists. + const policiesFound = ( + await sendGetAgentConfigList(http, { + query: { + kuery: `${AGENT_CONFIG_SAVED_OBJECT_TYPE}.package_configs: (${policyIdsToCheck.join( + ' or ' + )})`, + }, + }) + ).items.reduce((list, agentConfig) => { + (agentConfig.package_configs as string[]).forEach((packageConfig) => { + list[packageConfig as string] = true; + }); + return list; + }, {}); + + const nonExisting = policyIdsToCheck.reduce( + (list, policyId) => { + if (policiesFound[policyId]) { + return list; + } + list[policyId] = true; + return list; + }, + {} + ); + + if (Object.keys(nonExisting).length === 0) { + return; + } + + return nonExisting; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts index 993267cf1a704..7f68baa4b85bd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts @@ -28,6 +28,7 @@ export const initialHostListState: Immutable = { selectedPolicyId: undefined, policyItemsLoading: false, endpointPackageInfo: undefined, + nonExistingPolicies: {}, }; /* eslint-disable-next-line complexity */ @@ -57,6 +58,14 @@ export const hostListReducer: ImmutableReducer = ( error: action.payload, loading: false, }; + } else if (action.type === 'serverReturnedHostNonExistingPolicies') { + return { + ...state, + nonExistingPolicies: { + ...state.nonExistingPolicies, + ...action.payload, + }, + }; } else if (action.type === 'serverReturnedHostDetails') { return { ...state, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index 4f47eaf565d8c..6e0823a920413 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -195,3 +195,11 @@ export const policyResponseStatus: (state: Immutable) => string = cre return (policyResponse && policyResponse?.Endpoint?.policy?.applied?.status) || ''; } ); + +/** + * returns the list of known non-existing polices that may have been in the Host API response. + * @param state + */ +export const nonExistingPolicies: ( + state: Immutable +) => Immutable = (state) => state.nonExistingPolicies; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index a5f37a0b49e8f..582a59cfd7605 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -50,6 +50,8 @@ export interface HostState { selectedPolicyId?: string; /** Endpoint package info */ endpointPackageInfo?: GetPackagesResponse['response'][0]; + /** tracks the list of policies IDs used in Host metadata that may no longer exist */ + nonExistingPolicies: Record; } /** diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/host_policy_link.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/host_policy_link.tsx new file mode 100644 index 0000000000000..ec4d7e87b721d --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/host_policy_link.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, { memo, useMemo } from 'react'; +import { EuiLink, EuiLinkAnchorProps } from '@elastic/eui'; +import { useHostSelector } from '../hooks'; +import { nonExistingPolicies } from '../../store/selectors'; +import { getPolicyDetailPath } from '../../../../common/routing'; +import { useFormatUrl } from '../../../../../common/components/link_to'; +import { SecurityPageName } from '../../../../../../common/constants'; +import { useNavigateByRouterEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; + +/** + * A policy link (to details) that first checks to see if the policy id exists against + * the `nonExistingPolicies` value in the store. If it does not exist, then regular + * text is returned. + */ +export const HostPolicyLink = memo< + Omit & { + policyId: string; + } +>(({ policyId, children, onClick, ...otherProps }) => { + const missingPolicies = useHostSelector(nonExistingPolicies); + const { formatUrl } = useFormatUrl(SecurityPageName.administration); + const { toRoutePath, toRouteUrl } = useMemo(() => { + const toPath = getPolicyDetailPath(policyId); + return { + toRoutePath: toPath, + toRouteUrl: formatUrl(toPath), + }; + }, [formatUrl, policyId]); + const clickHandler = useNavigateByRouterEventHandler(toRoutePath, onClick); + + if (missingPolicies[policyId]) { + return ( + + {children} + + ); + } + + return ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + + {children} + + ); +}); + +HostPolicyLink.displayName = 'HostPolicyLink'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx index 62efa621e6e3b..cea66acbef8ca 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx @@ -26,10 +26,11 @@ import { POLICY_STATUS_TO_HEALTH_COLOR } from '../host_constants'; import { FormattedDateAndTime } from '../../../../../common/components/endpoint/formatted_date_time'; import { useNavigateByRouterEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; import { LinkToApp } from '../../../../../common/components/endpoint/link_to_app'; -import { getHostDetailsPath, getPolicyDetailPath } from '../../../../common/routing'; +import { getHostDetailsPath } from '../../../../common/routing'; import { SecurityPageName } from '../../../../../app/types'; import { useFormatUrl } from '../../../../../common/components/link_to'; import { AgentDetailsReassignConfigAction } from '../../../../../../../ingest_manager/public'; +import { HostPolicyLink } from '../components/host_policy_link'; const HostIds = styled(EuiListGroupItem)` margin-top: 0; @@ -116,15 +117,6 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { const policyStatusClickHandler = useNavigateByRouterEventHandler(policyResponseRoutePath); - const [policyDetailsRoutePath, policyDetailsRouteUrl] = useMemo(() => { - return [ - getPolicyDetailPath(details.Endpoint.policy.applied.id), - formatUrl(getPolicyDetailPath(details.Endpoint.policy.applied.id)), - ]; - }, [details.Endpoint.policy.applied.id, formatUrl]); - - const policyDetailsClickHandler = useNavigateByRouterEventHandler(policyDetailsRoutePath); - const detailsResultsPolicy = useMemo(() => { return [ { @@ -133,14 +125,12 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { }), description: ( <> - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} - {details.Endpoint.policy.applied.name} - + ), }, @@ -171,14 +161,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { ), }, ]; - }, [ - details, - policyResponseUri, - policyStatus, - policyStatusClickHandler, - policyDetailsRouteUrl, - policyDetailsClickHandler, - ]); + }, [details, policyResponseUri, policyStatus, policyStatusClickHandler]); const detailsResultsLower = useMemo(() => { return [ { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index c5ed71cba46d9..e38ef1bd5fe86 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -46,9 +46,10 @@ import { AgentConfigDetailsDeployAgentAction, } from '../../../../../../ingest_manager/public'; import { SecurityPageName } from '../../../../app/types'; -import { getHostListPath, getHostDetailsPath, getPolicyDetailPath } from '../../../common/routing'; +import { getHostListPath, getHostDetailsPath } from '../../../common/routing'; import { useFormatUrl } from '../../../../common/components/link_to'; import { HostAction } from '../store/action'; +import { HostPolicyLink } from './components/host_policy_link'; const HostListNavLink = memo<{ name: string; @@ -241,15 +242,14 @@ export const HostList = () => { truncateText: true, // eslint-disable-next-line react/display-name render: (policy: HostInfo['metadata']['Endpoint']['policy']['applied']) => { - const toRoutePath = getPolicyDetailPath(policy.id); - const toRouteUrl = formatUrl(toRoutePath); return ( - + + {policy.name} + ); }, }, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts index 48b6bedf50fd8..c6e6146f4d5e4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts @@ -12,12 +12,15 @@ import { DeletePackageConfigsRequest, PACKAGE_CONFIG_SAVED_OBJECT_TYPE, GetPackagesResponse, + GetAgentConfigsRequest, + GetAgentConfigsResponse, } from '../../../../../../../../ingest_manager/common'; import { GetPolicyListResponse, GetPolicyResponse, UpdatePolicyResponse } from '../../../types'; import { NewPolicyData } from '../../../../../../../common/endpoint/types'; const INGEST_API_ROOT = `/api/ingest_manager`; export const INGEST_API_PACKAGE_CONFIGS = `${INGEST_API_ROOT}/package_configs`; +const INGEST_API_AGENT_CONFIGS = `${INGEST_API_ROOT}/agent_configs`; const INGEST_API_FLEET = `${INGEST_API_ROOT}/fleet`; const INGEST_API_FLEET_AGENT_STATUS = `${INGEST_API_FLEET}/agent-status`; export const INGEST_API_EPM_PACKAGES = `${INGEST_API_ROOT}/epm/packages`; @@ -75,6 +78,18 @@ export const sendDeletePackageConfig = ( }); }; +/** + * Retrieve a list of Agent Configurations + * @param http + * @param options + */ +export const sendGetAgentConfigList = ( + http: HttpStart, + options: HttpFetchOptions & GetAgentConfigsRequest +) => { + return http.get(INGEST_API_AGENT_CONFIGS, options); +}; + /** * Updates a package config * From b15a0a97f742a4b1c2cbd424c3f842860b4331c0 Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Mon, 27 Jul 2020 10:35:18 -0500 Subject: [PATCH 17/17] [Security Solution][Detections] Adds ip_range and text types to value list upload form (#73109) * Adds two more types to the value lists form * Adds `ip_range` and `text` types * Replaces radio group with select * Add custom command for attaching a file to an input This will be used to excercise value list uploads. * Add some missing test subjects for our value lists modal * Add cypress test for value lists modal This exercises the happy path: opening the modal, uploading a list, and asserting that it subsequently appears in the table. Co-authored-by: Elastic Machine --- .../cypress/fixtures/value_list.txt | 6 +++ .../cypress/integration/value_lists.spec.ts | 43 ++++++++++++++++ .../cypress/screens/lists.ts | 11 ++++ .../cypress/support/commands.js | 19 +++++++ .../cypress/support/index.d.ts | 1 + .../security_solution/cypress/tasks/lists.ts | 36 +++++++++++++ .../value_lists_management_modal/form.tsx | 50 +++++++++---------- .../value_lists_management_modal/table.tsx | 1 + .../translations.ts | 14 ++++++ .../pages/detection_engine/rules/index.tsx | 1 + 10 files changed, 156 insertions(+), 26 deletions(-) create mode 100644 x-pack/plugins/security_solution/cypress/fixtures/value_list.txt create mode 100644 x-pack/plugins/security_solution/cypress/integration/value_lists.spec.ts create mode 100644 x-pack/plugins/security_solution/cypress/screens/lists.ts create mode 100644 x-pack/plugins/security_solution/cypress/tasks/lists.ts diff --git a/x-pack/plugins/security_solution/cypress/fixtures/value_list.txt b/x-pack/plugins/security_solution/cypress/fixtures/value_list.txt new file mode 100644 index 0000000000000..2b40f036c62d2 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/fixtures/value_list.txt @@ -0,0 +1,6 @@ +these +are +keywords +for +a +list diff --git a/x-pack/plugins/security_solution/cypress/integration/value_lists.spec.ts b/x-pack/plugins/security_solution/cypress/integration/value_lists.spec.ts new file mode 100644 index 0000000000000..2804a8ac2ea8c --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/value_lists.spec.ts @@ -0,0 +1,43 @@ +/* + * 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 { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; +import { DETECTIONS_URL } from '../urls/navigation'; +import { + waitForAlertsPanelToBeLoaded, + waitForAlertsIndexToBeCreated, + goToManageAlertsDetectionRules, +} from '../tasks/alerts'; +import { + waitForListsIndexToBeCreated, + waitForValueListsModalToBeLoaded, + openValueListsModal, + selectValueListsFile, + uploadValueList, +} from '../tasks/lists'; +import { VALUE_LISTS_TABLE, VALUE_LISTS_ROW } from '../screens/lists'; + +describe('value lists', () => { + describe('management modal', () => { + it('creates a keyword list from an uploaded file', () => { + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + waitForAlertsPanelToBeLoaded(); + waitForAlertsIndexToBeCreated(); + waitForListsIndexToBeCreated(); + goToManageAlertsDetectionRules(); + waitForValueListsModalToBeLoaded(); + openValueListsModal(); + selectValueListsFile(); + uploadValueList(); + + cy.get(VALUE_LISTS_TABLE) + .find(VALUE_LISTS_ROW) + .should(($row) => { + expect($row.text()).to.contain('value_list.txt'); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/lists.ts b/x-pack/plugins/security_solution/cypress/screens/lists.ts new file mode 100644 index 0000000000000..35205a27e5a3c --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/lists.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const VALUE_LISTS_MODAL_ACTIVATOR = '[data-test-subj="open-value-lists-modal-button"]'; +export const VALUE_LISTS_TABLE = '[data-test-subj="value-lists-table"]'; +export const VALUE_LISTS_ROW = '.euiTableRow'; +export const VALUE_LIST_FILE_PICKER = '[data-test-subj="value-list-file-picker"]'; +export const VALUE_LIST_FILE_UPLOAD_BUTTON = '[data-test-subj="value-lists-form-import-action"]'; diff --git a/x-pack/plugins/security_solution/cypress/support/commands.js b/x-pack/plugins/security_solution/cypress/support/commands.js index 8b75f068a53da..789759643e319 100644 --- a/x-pack/plugins/security_solution/cypress/support/commands.js +++ b/x-pack/plugins/security_solution/cypress/support/commands.js @@ -39,3 +39,22 @@ Cypress.Commands.add('stubSecurityApi', function (dataFileName) { cy.fixture(dataFileName).as(`${dataFileName}JSON`); cy.route('POST', 'api/solutions/security/graphql', `@${dataFileName}JSON`); }); + +Cypress.Commands.add( + 'attachFile', + { + prevSubject: 'element', + }, + (input, fileName, fileType = 'text/plain') => { + cy.fixture(fileName) + .then((content) => Cypress.Blob.base64StringToBlob(content, fileType)) + .then((blob) => { + const testFile = new File([blob], fileName, { type: fileType }); + const dataTransfer = new DataTransfer(); + + dataTransfer.items.add(testFile); + input[0].files = dataTransfer.files; + return input; + }); + } +); diff --git a/x-pack/plugins/security_solution/cypress/support/index.d.ts b/x-pack/plugins/security_solution/cypress/support/index.d.ts index 12c11ffd27750..906e526e2c4a0 100644 --- a/x-pack/plugins/security_solution/cypress/support/index.d.ts +++ b/x-pack/plugins/security_solution/cypress/support/index.d.ts @@ -7,5 +7,6 @@ declare namespace Cypress { interface Chainable { stubSecurityApi(dataFileName: string): Chainable; + attachFile(fileName: string, fileType?: string): Chainable; } } diff --git a/x-pack/plugins/security_solution/cypress/tasks/lists.ts b/x-pack/plugins/security_solution/cypress/tasks/lists.ts new file mode 100644 index 0000000000000..638c69c087adf --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/lists.ts @@ -0,0 +1,36 @@ +/* + * 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 { + VALUE_LISTS_MODAL_ACTIVATOR, + VALUE_LIST_FILE_PICKER, + VALUE_LIST_FILE_UPLOAD_BUTTON, +} from '../screens/lists'; + +export const waitForListsIndexToBeCreated = () => { + cy.request({ url: '/api/lists/index', retryOnStatusCodeFailure: true }).then((response) => { + if (response.status !== 200) { + cy.wait(7500); + } + }); +}; + +export const waitForValueListsModalToBeLoaded = () => { + cy.get(VALUE_LISTS_MODAL_ACTIVATOR).should('exist'); + cy.get(VALUE_LISTS_MODAL_ACTIVATOR).should('not.be.disabled'); +}; + +export const openValueListsModal = () => { + cy.get(VALUE_LISTS_MODAL_ACTIVATOR).click(); +}; + +export const selectValueListsFile = () => { + cy.get(VALUE_LIST_FILE_PICKER).attachFile('value_list.txt').trigger('change', { force: true }); +}; + +export const uploadValueList = () => { + cy.get(VALUE_LIST_FILE_UPLOAD_BUTTON).click(); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx index aab665289e80d..c35cc612129d5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useState, ReactNode, useEffect, useRef } from 'react'; -import styled from 'styled-components'; +import React, { useCallback, useState, useEffect, useRef } from 'react'; import { EuiButton, EuiButtonEmpty, @@ -14,34 +13,30 @@ import { EuiFilePicker, EuiFlexGroup, EuiFlexItem, - EuiRadioGroup, + EuiSelect, + EuiSelectOption, } from '@elastic/eui'; import { useImportList, ListSchema, Type } from '../../../shared_imports'; import * as i18n from './translations'; import { useKibana } from '../../../common/lib/kibana'; -const InlineRadioGroup = styled(EuiRadioGroup)` - display: flex; - - .euiRadioGroup__item + .euiRadioGroup__item { - margin: 0 0 0 12px; - } -`; - -interface ListTypeOptions { - id: Type; - label: ReactNode; -} - -const options: ListTypeOptions[] = [ +const options: EuiSelectOption[] = [ { - id: 'keyword', - label: i18n.KEYWORDS_RADIO, + value: 'keyword', + text: i18n.KEYWORDS_RADIO, }, { - id: 'ip', - label: i18n.IP_RADIO, + value: 'ip', + text: i18n.IP_RADIO, + }, + { + value: 'ip_range', + text: i18n.IP_RANGE_RADIO, + }, + { + value: 'text', + text: i18n.TEXT_RADIO, }, ]; @@ -63,8 +58,10 @@ export const ValueListsFormComponent: React.FC = ({ onError const fileIsValid = !file || validFileTypes.some((fileType) => file.type === fileType); - // EuiRadioGroup's onChange only infers 'string' from our options - const handleRadioChange = useCallback((t: string) => setType(t as Type), [setType]); + const handleRadioChange = useCallback( + (event: React.ChangeEvent) => setType(event.target.value as Type), + [setType] + ); const handleFileChange = useCallback((files: FileList | null) => { setFile(files?.item(0) ?? null); @@ -133,6 +130,7 @@ export const ValueListsFormComponent: React.FC = ({ onError > = ({ onError - - + {importState.loading && ( diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx index 850716ce54e26..a2e3b73a0abf0 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx @@ -35,6 +35,7 @@ export const ValueListsTableComponent: React.FC = ({

{i18n.TABLE_TITLE}

{ )}