diff --git a/docs/developer/advanced/running-elasticsearch.asciidoc b/docs/developer/advanced/running-elasticsearch.asciidoc index 2361f805c76359..e5c86fafd1ce78 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 (<>). diff --git a/docs/maps/trouble-shooting.asciidoc b/docs/maps/trouble-shooting.asciidoc index cfc47cf6f0e4ff..1c53fbd55ea4b5 100644 --- a/docs/maps/trouble-shooting.asciidoc +++ b/docs/maps/trouble-shooting.asciidoc @@ -20,6 +20,20 @@ image::maps/images/inspector.png[] [float] === Solutions to common problems +[float] +==== Index not listed when adding layer + +* Verify your geospatial data is correctly mapped as {ref}/geo-point.html[geo_point] or {ref}/geo-shape.html[geo_shape]. + ** Run `GET myIndexPatternTitle/_field_caps?fields=myGeoFieldName` in <>, replacing `myIndexPatternTitle` and `myGeoFieldName` with your index pattern title and geospatial field name. + ** Ensure response specifies `type` as `geo_point` or `geo_shape`. +* Verify your geospatial data is correctly mapped in your <>. + ** Open your index pattern in <>. + ** Ensure your geospatial field type is `geo_point` or `geo_shape`. + ** Ensure your geospatial field is searchable and aggregatable. + ** If your geospatial field type does not match your Elasticsearch mapping, click the *Refresh* button to refresh the field list from Elasticsearch. +* Index patterns with thousands of fields can exceed the default maximum payload size. +Increase <> for large index patterns. + [float] ==== Features are not displayed diff --git a/packages/kbn-spec-to-console/README.md b/packages/kbn-spec-to-console/README.md index 526ceef43e3cdd..0328dec791320c 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 diff --git a/src/core/server/elasticsearch/client/configure_client.test.ts b/src/core/server/elasticsearch/client/configure_client.test.ts index 32da142764a786..11e3199a79fd2e 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 5377f8ca1b0706..9746ecb538b75f 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 b8125de2ee498e..af63dfa6c7f4ec 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 ec2885dfdf922c..c93294404b52ff 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 a7177c0b290477..3aa47e8b40e24e 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 1ad039e5122157..792f7f0a7fac9c 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 7ce998aab76692..285f52e89a591e 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 4375f09f1ce0b1..49f5c8dd987902 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 8bb77b5dfdee07..32be6e6bf34dd2 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 165980b9f45223..a1740faac7ddff 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 62789a4fe952d7..00000000000000 --- 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 1b05cb2bf13cda..00000000000000 --- 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 21adac081acf79..f6313f68abff29 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 6338326626d546..6a00db5a6cc4a6 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 706ec88c6ebfda..c846e81573acba 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 76bcc6ee219d99..6bd567be204d0d 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 393cbb7fbb2aef..fb8fb4ef950811 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 e87c3e3ff0d64b..d5093bfd8dc421 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 f7274740ea5fe3..c9d3d2a71c9ad2 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 f8b203bf66d6ab..78601d033f8d8b 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 e588eb7877322a..ceca27fa87723f 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 adf1851a1aa753..0ea362d65623e2 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 00000000000000..8ebed25d87cbac --- /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 00000000000000..40c06677c4a5a5 --- /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 00000000000000..ff859057f8fe80 --- /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 01b0d1cd0ba3af..c3ed97a89af800 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 69b57a498936e8..85b9099308807d 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 e8b2cf0b583b1b..8df6a07318c456 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 c2d4f49d7ee2ac..f05e912b12ad89 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 f7f4e75704341d..812a0770ad988a 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 1fdebd87397eb5..623610eebd8d7f 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 7d1575798c3575..cf8a16cdaae6fe 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 d563edbe66c9b6..b902179b012ff2 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 7a5ac9204627c0..8b7b1d62c1b7de 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 00000000000000..3dcf82dae5e461 --- /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 00000000000000..86a984fb671246 --- /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 00000000000000..0a759669b1af8b --- /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/es_ui_shared/__packages_do_not_import__/global_flyout/global_flyout.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/global_flyout.tsx new file mode 100644 index 00000000000000..aa575cd64944cd --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/global_flyout.tsx @@ -0,0 +1,166 @@ +/* + * 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 React, { + createContext, + useContext, + useState, + useCallback, + useMemo, + useEffect, + useRef, +} from 'react'; +import { EuiFlyout } from '@elastic/eui'; + +interface Context { + addContent:

(content: Content

) => void; + removeContent: (contentId: string) => void; + closeFlyout: () => void; +} + +interface Content

{ + id: string; + Component: React.FunctionComponent

; + props?: P; + flyoutProps?: { [key: string]: any }; + cleanUpFunc?: () => void; +} + +const FlyoutMultiContentContext = createContext(undefined); + +const DEFAULT_FLYOUT_PROPS = { + 'data-test-subj': 'flyout', + size: 'm' as 'm', + maxWidth: 500, +}; + +export const GlobalFlyoutProvider: React.FC = ({ children }) => { + const [showFlyout, setShowFlyout] = useState(false); + const [activeContent, setActiveContent] = useState | undefined>(undefined); + + const { id, Component, props, flyoutProps } = activeContent ?? {}; + + const addContent: Context['addContent'] = useCallback((content) => { + setActiveContent((prev) => { + if (prev !== undefined) { + if (prev.id !== content.id && prev.cleanUpFunc) { + // Clean up anything from the content about to be removed + prev.cleanUpFunc(); + } + } + return content; + }); + + setShowFlyout(true); + }, []); + + const closeFlyout: Context['closeFlyout'] = useCallback(() => { + setActiveContent(undefined); + setShowFlyout(false); + }, []); + + const removeContent: Context['removeContent'] = useCallback( + (contentId: string) => { + if (contentId === id) { + closeFlyout(); + } + }, + [id, closeFlyout] + ); + + const mergedFlyoutProps = useMemo(() => { + return { + ...DEFAULT_FLYOUT_PROPS, + onClose: closeFlyout, + ...flyoutProps, + }; + }, [flyoutProps, closeFlyout]); + + const context: Context = { + addContent, + removeContent, + closeFlyout, + }; + + const ContentFlyout = showFlyout && Component !== undefined ? Component : null; + + return ( + + <> + {children} + {ContentFlyout && ( + + + + )} + + + ); +}; + +export const useGlobalFlyout = () => { + const ctx = useContext(FlyoutMultiContentContext); + + if (ctx === undefined) { + throw new Error('useGlobalFlyout must be used within a '); + } + + const isMounted = useRef(false); + /** + * A component can add one or multiple content to the flyout + * during its lifecycle. When it unmounts, we will remove + * all those content added to the flyout. + */ + const contents = useRef | undefined>(undefined); + const { removeContent, addContent: addContentToContext } = ctx; + + useEffect(() => { + isMounted.current = true; + + return () => { + isMounted.current = false; + }; + }, []); + + const getContents = useCallback(() => { + if (contents.current === undefined) { + contents.current = new Set(); + } + return contents.current; + }, []); + + const addContent: Context['addContent'] = useCallback( + (content) => { + getContents().add(content.id); + return addContentToContext(content); + }, + [getContents, addContentToContext] + ); + + useEffect(() => { + return () => { + if (!isMounted.current) { + // When the component unmounts, remove all the content it has added to the flyout + Array.from(getContents()).forEach(removeContent); + } + }; + }, [removeContent]); + + return { ...ctx, addContent }; +}; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/index.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/index.ts new file mode 100644 index 00000000000000..c49692547fb252 --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { GlobalFlyoutProvider, useGlobalFlyout } from './global_flyout'; diff --git a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard.tsx b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard.tsx index cdb332e9e9130d..642a21eae50e9c 100644 --- a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard.tsx +++ b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard.tsx @@ -27,13 +27,14 @@ import { } from './form_wizard_context'; import { FormWizardNav, NavTexts } from './form_wizard_nav'; -interface Props extends ProviderProps { +interface Props extends ProviderProps { isSaving?: boolean; apiError: JSX.Element | null; texts?: Partial; + rightContentNav?: JSX.Element | null | ((stepId: S) => JSX.Element | null); } -export function FormWizard({ +export function FormWizard({ texts, defaultActiveStep, defaultValue, @@ -43,7 +44,8 @@ export function FormWizard({ onSave, onChange, children, -}: Props) { + rightContentNav, +}: Props) { return ( defaultValue={defaultValue} @@ -53,7 +55,14 @@ export function FormWizard({ defaultActiveStep={defaultActiveStep} > - {({ activeStepIndex, lastStep, steps, isCurrentStepValid, navigateToStep }) => { + {({ + activeStepIndex, + lastStep, + steps, + isCurrentStepValid, + navigateToStep, + activeStepId, + }) => { const stepsRequiredArray = Object.values(steps).map( (step) => Boolean(step.isRequired) && step.isComplete === false ); @@ -95,6 +104,13 @@ export function FormWizard({ }; }); + const getRightContentNav = () => { + if (typeof rightContentNav === 'function') { + return rightContentNav(activeStepId); + } + return rightContentNav; + }; + const onBack = () => { const prevStep = activeStepIndex - 1; navigateToStep(prevStep); @@ -129,6 +145,7 @@ export function FormWizard({ onBack={onBack} onNext={onNext} texts={texts} + getRightContent={getRightContentNav} /> ); diff --git a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_nav.tsx b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_nav.tsx index 3e0e9cf897b5d2..0af99e8bce35ae 100644 --- a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_nav.tsx +++ b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_nav.tsx @@ -29,6 +29,7 @@ interface Props { isSaving?: boolean; isStepValid?: boolean; texts?: Partial; + getRightContent?: () => JSX.Element | null | undefined; } export interface NavTexts { @@ -53,6 +54,7 @@ export const FormWizardNav = ({ onBack, onNext, texts, + getRightContent, }: Props) => { const isLastStep = activeStepIndex === lastStep; const labels = { @@ -66,6 +68,8 @@ export const FormWizardNav = ({ : labels.save : labels.next; + const rightContent = getRightContent !== undefined ? getRightContent() : undefined; + return ( @@ -100,6 +104,8 @@ export const FormWizardNav = ({ + + {rightContent && {rightContent}} ); }; diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts b/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts index 8d470f6454b0e5..2e7c91a26e1fc8 100644 --- a/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts +++ b/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts @@ -94,7 +94,7 @@ export function useMultiContent({ const activeContentData: Partial = {}; for (const [id, _content] of Object.entries(contents.current)) { - if (validation.contents[id as keyof T]) { + if (validation.contents[id as keyof T] !== false) { const contentData = (_content as Content).getData(); // Replace the getData() handler with the cached value @@ -161,7 +161,7 @@ export function useMultiContent({ ); /** - * Validate the multi-content active content(s) in the DOM + * Validate the content(s) currently in the DOM */ const validate = useCallback(async () => { if (Object.keys(contents.current).length === 0) { diff --git a/src/plugins/es_ui_shared/public/global_flyout/index.ts b/src/plugins/es_ui_shared/public/global_flyout/index.ts new file mode 100644 index 00000000000000..e876594337c1ee --- /dev/null +++ b/src/plugins/es_ui_shared/public/global_flyout/index.ts @@ -0,0 +1,23 @@ +/* + * 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 { + GlobalFlyoutProvider, + useGlobalFlyout, +} from '../../__packages_do_not_import__/global_flyout'; diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts index 98a305fe68f083..bdea5ccf5fe26f 100644 --- a/src/plugins/es_ui_shared/public/index.ts +++ b/src/plugins/es_ui_shared/public/index.ts @@ -24,6 +24,7 @@ import * as Forms from './forms'; import * as Monaco from './monaco'; import * as ace from './ace'; +import * as GlobalFlyout from './global_flyout'; export { JsonEditor, OnJsonEditorUpdateHandler } from './components/json_editor'; @@ -65,7 +66,7 @@ export { useAuthorizationContext, } from './authorization'; -export { Monaco, Forms, ace }; +export { Monaco, Forms, ace, GlobalFlyout }; export { extractQueryParams } from './url'; diff --git a/src/plugins/es_ui_shared/static/forms/helpers/serializers.ts b/src/plugins/es_ui_shared/static/forms/helpers/serializers.ts index 98287f6bac35db..733a60f1f86ff0 100644 --- a/src/plugins/es_ui_shared/static/forms/helpers/serializers.ts +++ b/src/plugins/es_ui_shared/static/forms/helpers/serializers.ts @@ -64,9 +64,13 @@ interface StripEmptyFieldsOptions { * @param options An optional configuration object. By default recursive it turned on. */ export const stripEmptyFields = ( - object: { [key: string]: any }, + object?: { [key: string]: any }, options?: StripEmptyFieldsOptions ): { [key: string]: any } => { + if (object === undefined) { + return {}; + } + const { types = ['string', 'object'], recursive = false } = options || {}; return Object.entries(object).reduce((acc, [key, value]) => { 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 770b3116b74f15..7cce0fa5cccb35 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/src/plugins/timelion/public/directives/timelion_expression_suggestions/__tests__/timelion_expression_suggestions.js b/src/plugins/timelion/public/directives/timelion_expression_suggestions/__tests__/timelion_expression_suggestions.js deleted file mode 100644 index 8a35a72ed19e60..00000000000000 --- a/src/plugins/timelion/public/directives/timelion_expression_suggestions/__tests__/timelion_expression_suggestions.js +++ /dev/null @@ -1,58 +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 expect from '@kbn/expect'; -import ngMock from 'ng_mock'; -import '../timelion_expression_suggestions'; - -describe('Timelion expression suggestions directive', function () { - let scope; - let $compile; - - beforeEach(ngMock.module('kibana')); - - beforeEach( - ngMock.inject(function ($injector) { - $compile = $injector.get('$compile'); - scope = $injector.get('$rootScope').$new(); - }) - ); - - describe('attributes', function () { - describe('suggestions', function () { - let element = null; - const template = ``; - - beforeEach(function () { - element = $compile(template)(scope); - scope.$apply(() => { - scope.list = [{ name: 'suggestion1' }, { name: 'suggestion2' }, { name: 'suggestion3' }]; - }); - }); - - it('are rendered', function () { - expect(element.find('[data-test-subj="timelionSuggestionListItem"]').length).to.be( - scope.list.length - ); - }); - }); - }); -}); 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 ed259ccec0114c..9997d9710e2126 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/test/functional/apps/management/_handle_alias.js b/test/functional/apps/management/_handle_alias.js index 902b49eacdc00c..67a4445d17aa01 100644 --- a/test/functional/apps/management/_handle_alias.js +++ b/test/functional/apps/management/_handle_alias.js @@ -26,8 +26,7 @@ export default function ({ getService, getPageObjects }) { const security = getService('security'); const PageObjects = getPageObjects(['common', 'home', 'settings', 'discover', 'timePicker']); - // FLAKY: https://github.com/elastic/kibana/issues/59717 - describe.skip('Index patterns on aliases', function () { + describe('Index patterns on aliases', function () { before(async function () { await security.testUser.setRoles(['kibana_admin', 'test_alias_reader']); await esArchiver.loadIfNeeded('alias'); @@ -50,9 +49,8 @@ export default function ({ getService, getPageObjects }) { }); it('should be able to create index pattern without time field', async function () { - await PageObjects.settings.createIndexPattern('alias1', null); - const patternName = await PageObjects.settings.getIndexPageHeading(); - expect(patternName).to.be('alias1*'); + await PageObjects.settings.navigateTo(); + await PageObjects.settings.createIndexPattern('alias1*', null); }); it('should be able to discover and verify no of hits for alias1', async function () { @@ -64,9 +62,8 @@ export default function ({ getService, getPageObjects }) { }); it('should be able to create index pattern with timefield', async function () { - await PageObjects.settings.createIndexPattern('alias2', 'date'); - const patternName = await PageObjects.settings.getIndexPageHeading(); - expect(patternName).to.be('alias2*'); + await PageObjects.settings.navigateTo(); + await PageObjects.settings.createIndexPattern('alias2*', 'date'); }); it('should be able to discover and verify no of hits for alias2', async function () { diff --git a/test/functional/apps/visualize/_gauge_chart.js b/test/functional/apps/visualize/_gauge_chart.js index aa94e596319c29..0f870b1fb545fb 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'); 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 c530c6f823133b..6c0b77411ae993 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 488f4cfd0d0ce1..b9906911b00f1f 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 5288bd9c7b924b..00000000000000 Binary files a/test/functional/screenshots/baseline/vega_chart.png and /dev/null differ diff --git a/test/functional/screenshots/baseline/vega_chart_filtered.png b/test/functional/screenshots/baseline/vega_chart_filtered.png deleted file mode 100644 index 974ede74095d20..00000000000000 Binary files a/test/functional/screenshots/baseline/vega_chart_filtered.png and /dev/null differ 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 a44fad82f20e64..10651d97f3c3de 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'], }, }); 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 2836cf100a4324..6f4f92c6833f78 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/canvas/public/application.tsx b/x-pack/plugins/canvas/public/application.tsx index b2c836fe4805f0..0bbf449ce11f99 100644 --- a/x-pack/plugins/canvas/public/application.tsx +++ b/x-pack/plugins/canvas/public/application.tsx @@ -31,7 +31,7 @@ import { init as initStatsReporter } from './lib/ui_metric'; import { CapabilitiesStrings } from '../i18n'; -import { startServices, services } from './services'; +import { startServices, services, ServicesProvider } from './services'; // @ts-expect-error untyped local import { createHistory, destroyHistory } from './lib/history_provider'; // @ts-expect-error untyped local @@ -52,19 +52,16 @@ export const renderApp = ( ) => { element.classList.add('canvas'); element.classList.add('canvasContainerWrapper'); - const canvasServices = Object.entries(services).reduce((reduction, [key, provider]) => { - reduction[key] = provider.getService(); - - return reduction; - }, {} as Record); ReactDOM.render( - - - - - - + + + + + + + + , element ); diff --git a/x-pack/plugins/canvas/public/components/app/index.js b/x-pack/plugins/canvas/public/components/app/index.js index a1e3b9c09554ad..9a6e8719e7f409 100644 --- a/x-pack/plugins/canvas/public/components/app/index.js +++ b/x-pack/plugins/canvas/public/components/app/index.js @@ -8,7 +8,7 @@ import { connect } from 'react-redux'; import { compose, withProps } from 'recompose'; import { getAppReady, getBasePath } from '../../state/selectors/app'; import { appReady, appError } from '../../state/actions/app'; -import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { withServices } from '../../services'; import { App as Component } from './app'; @@ -45,8 +45,8 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { export const App = compose( connect(mapStateToProps, mapDispatchToProps, mergeProps), - withKibana, + withServices, withProps((props) => ({ - onRouteChange: props.kibana.services.canvas.navLink.updatePath, + onRouteChange: props.services.navLink.updatePath, })) )(Component); diff --git a/x-pack/plugins/canvas/public/components/app/track_route_change.js b/x-pack/plugins/canvas/public/components/app/track_route_change.js deleted file mode 100644 index 2886aa868eb9e1..00000000000000 --- a/x-pack/plugins/canvas/public/components/app/track_route_change.js +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { get } from 'lodash'; -import { getWindow } from '../../lib/get_window'; -import { CANVAS_APP } from '../../../common/lib/constants'; -import { platformService } from '../../services'; - -export function trackRouteChange() { - const basePath = platformService.getService().coreStart.http.basePath.get(); - - platformService - .getService() - .startPlugins.__LEGACY.trackSubUrlForApp( - CANVAS_APP, - platformService - .getService() - .startPlugins.__LEGACY.absoluteToParsedUrl(get(getWindow(), 'location.href'), basePath) - ); -} diff --git a/x-pack/plugins/canvas/public/components/element_content/index.js b/x-pack/plugins/canvas/public/components/element_content/index.js index a138c3acb8ec76..63ece6ac32812b 100644 --- a/x-pack/plugins/canvas/public/components/element_content/index.js +++ b/x-pack/plugins/canvas/public/components/element_content/index.js @@ -8,8 +8,8 @@ import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { compose, withProps } from 'recompose'; import { get } from 'lodash'; +import { withServices } from '../../services'; import { getSelectedPage, getPageById } from '../../state/selectors/workpad'; -import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; import { ElementContent as Component } from './element_content'; const mapStateToProps = (state) => ({ @@ -18,9 +18,9 @@ const mapStateToProps = (state) => ({ export const ElementContent = compose( connect(mapStateToProps), - withKibana, - withProps(({ renderable, kibana }) => ({ - renderFunction: kibana.services.expressions.getRenderer(get(renderable, 'as')), + withServices, + withProps(({ renderable, services }) => ({ + renderFunction: services.expressions.getRenderer(get(renderable, 'as')), })) )(Component); diff --git a/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.tsx b/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.tsx index df9dad3e7f6784..0b5bd8adf8cb97 100644 --- a/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.tsx +++ b/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.tsx @@ -4,15 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { FC } from 'react'; import { EuiFlyout, EuiFlyoutHeader, EuiFlyoutBody, EuiTitle } from '@elastic/eui'; import { SavedObjectFinderUi, SavedObjectMetaData, } from '../../../../../../src/plugins/saved_objects/public/'; import { ComponentStrings } from '../../../i18n'; -import { CoreStart } from '../../../../../../src/core/public'; -import { CanvasStartDeps } from '../../plugin'; +import { useServices } from '../../services'; const { AddEmbeddableFlyout: strings } = ComponentStrings; @@ -20,14 +19,16 @@ export interface Props { onClose: () => void; onSelect: (id: string, embeddableType: string) => void; availableEmbeddables: string[]; - savedObjects: CoreStart['savedObjects']; - uiSettings: CoreStart['uiSettings']; - getEmbeddableFactories: CanvasStartDeps['embeddable']['getEmbeddableFactories']; } -export class AddEmbeddableFlyout extends React.Component { - onAddPanel = (id: string, savedObjectType: string, name: string) => { - const embeddableFactories = this.props.getEmbeddableFactories(); +export const AddEmbeddableFlyout: FC = ({ onSelect, availableEmbeddables, onClose }) => { + const services = useServices(); + const { embeddables, platform } = services; + const { getEmbeddableFactories } = embeddables; + const { getSavedObjects, getUISettings } = platform; + + const onAddPanel = (id: string, savedObjectType: string, name: string) => { + const embeddableFactories = getEmbeddableFactories(); // Find the embeddable type from the saved object type const found = Array.from(embeddableFactories).find((embeddableFactory) => { @@ -39,41 +40,39 @@ export class AddEmbeddableFlyout extends React.Component { const foundEmbeddableType = found ? found.type : 'unknown'; - this.props.onSelect(id, foundEmbeddableType); + onSelect(id, foundEmbeddableType); }; - render() { - const embeddableFactories = this.props.getEmbeddableFactories(); + const embeddableFactories = getEmbeddableFactories(); - const availableSavedObjects = Array.from(embeddableFactories) - .filter((factory) => { - return this.props.availableEmbeddables.includes(factory.type); - }) - .map((factory) => factory.savedObjectMetaData) - .filter>(function ( - maybeSavedObjectMetaData - ): maybeSavedObjectMetaData is SavedObjectMetaData<{}> { - return maybeSavedObjectMetaData !== undefined; - }); + const availableSavedObjects = Array.from(embeddableFactories) + .filter((factory) => { + return availableEmbeddables.includes(factory.type); + }) + .map((factory) => factory.savedObjectMetaData) + .filter>(function ( + maybeSavedObjectMetaData + ): maybeSavedObjectMetaData is SavedObjectMetaData<{}> { + return maybeSavedObjectMetaData !== undefined; + }); - return ( - - - -

{strings.getTitleText()}

- - - - - - - ); - } -} + return ( + + + +

{strings.getTitleText()}

+
+
+ + + +
+ ); +}; diff --git a/x-pack/plugins/canvas/public/components/embeddable_flyout/index.tsx b/x-pack/plugins/canvas/public/components/embeddable_flyout/index.tsx index 9462ba0411de47..62a073daf4c59d 100644 --- a/x-pack/plugins/canvas/public/components/embeddable_flyout/index.tsx +++ b/x-pack/plugins/canvas/public/components/embeddable_flyout/index.tsx @@ -14,8 +14,6 @@ import { AddEmbeddableFlyout, Props } from './flyout'; import { addElement } from '../../state/actions/elements'; import { getSelectedPage } from '../../state/selectors/workpad'; import { EmbeddableTypes } from '../../../canvas_plugin_src/expression_types/embeddable'; -import { WithKibanaProps } from '../../index'; -import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; const allowedEmbeddables = { [EmbeddableTypes.map]: (id: string) => { @@ -74,10 +72,10 @@ const mergeProps = ( }; }; -export class EmbeddableFlyoutPortal extends React.Component { +export class EmbeddableFlyoutPortal extends React.Component { el?: HTMLElement; - constructor(props: Props & WithKibanaProps) { + constructor(props: Props) { super(props); this.el = document.createElement('div'); @@ -103,9 +101,6 @@ export class EmbeddableFlyoutPortal extends React.Component, this.el ); @@ -113,7 +108,6 @@ export class EmbeddableFlyoutPortal extends React.Component void }>( - connect(mapStateToProps, mapDispatchToProps, mergeProps), - withKibana +export const AddEmbeddablePanel = compose void }>( + connect(mapStateToProps, mapDispatchToProps, mergeProps) )(EmbeddableFlyoutPortal); diff --git a/x-pack/plugins/canvas/public/components/expression/index.js b/x-pack/plugins/canvas/public/components/expression/index.js index 4480169dd037d6..146acbcd6c6ee6 100644 --- a/x-pack/plugins/canvas/public/components/expression/index.js +++ b/x-pack/plugins/canvas/public/components/expression/index.js @@ -15,7 +15,7 @@ import { renderComponent, } from 'recompose'; import { fromExpression } from '@kbn/interpreter/common'; -import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { withServices } from '../../services'; import { getSelectedPage, getSelectedElement } from '../../state/selectors/workpad'; import { setExpression, flushContext } from '../../state/actions/elements'; import { ElementNotSelected } from './element_not_selected'; @@ -46,7 +46,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { const { expression } = element; - const functions = Object.values(allProps.kibana.services.expressions.getFunctions()); + const functions = Object.values(allProps.services.expressions.getFunctions()); return { ...allProps, @@ -71,7 +71,7 @@ const expressionLifecycle = lifecycle({ }); export const Expression = compose( - withKibana, + withServices, connect(mapStateToProps, mapDispatchToProps, mergeProps), withState('formState', 'setFormState', ({ expression }) => ({ expression, diff --git a/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.tsx b/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.tsx index bc51128cf0c876..7939c1d04631aa 100644 --- a/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.tsx +++ b/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.tsx @@ -7,7 +7,7 @@ import React, { useState, useEffect, useRef, FC, useCallback } from 'react'; import { useDebounce } from 'react-use'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { useNotifyService } from '../../services'; import { RenderToDom } from '../render_to_dom'; import { ErrorStrings } from '../../../i18n'; import { RendererHandlers } from '../../../types'; @@ -39,8 +39,7 @@ export const RenderWithFn: FC = ({ width, height, }) => { - const { services } = useKibana(); - const onError = services.canvas.notify.error; + const { error: onError } = useNotifyService(); const [domNode, setDomNode] = useState(null); diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/index.ts b/x-pack/plugins/canvas/public/components/saved_elements_modal/index.ts index c5c1dbc2fdd6e8..da2955c1461939 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/index.ts +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/index.ts @@ -10,8 +10,7 @@ import { compose, withState } from 'recompose'; import { camelCase } from 'lodash'; import { cloneSubgraphs } from '../../lib/clone_subgraphs'; import * as customElementService from '../../lib/custom_element_service'; -import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { WithKibanaProps } from '../../'; +import { withServices, WithServicesProps } from '../../services'; // @ts-expect-error untyped local import { selectToplevelNodes } from '../../state/actions/transient'; // @ts-expect-error untyped local @@ -63,7 +62,7 @@ const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ const mergeProps = ( stateProps: StateProps, dispatchProps: DispatchProps, - ownProps: OwnPropsWithState & WithKibanaProps + ownProps: OwnPropsWithState & WithServicesProps ): ComponentProps => { const { pageId } = stateProps; const { onClose, search, setCustomElements } = ownProps; @@ -91,7 +90,7 @@ const mergeProps = ( try { await findCustomElements(); } catch (err) { - ownProps.kibana.services.canvas.notify.error(err, { + ownProps.services.notify.error(err, { title: `Couldn't find custom elements`, }); } @@ -102,7 +101,7 @@ const mergeProps = ( await customElementService.remove(id); await findCustomElements(); } catch (err) { - ownProps.kibana.services.canvas.notify.error(err, { + ownProps.services.notify.error(err, { title: `Couldn't delete custom elements`, }); } @@ -118,7 +117,7 @@ const mergeProps = ( }); await findCustomElements(); } catch (err) { - ownProps.kibana.services.canvas.notify.error(err, { + ownProps.services.notify.error(err, { title: `Couldn't update custom elements`, }); } @@ -127,7 +126,7 @@ const mergeProps = ( }; export const SavedElementsModal = compose( - withKibana, + withServices, withState('search', 'setSearch', ''), withState('customElements', 'setCustomElements', []), connect(mapStateToProps, mapDispatchToProps, mergeProps) diff --git a/x-pack/plugins/canvas/public/components/var_config/index.tsx b/x-pack/plugins/canvas/public/components/var_config/index.tsx index 526037b79e0e0a..ca40bd07877f0a 100644 --- a/x-pack/plugins/canvas/public/components/var_config/index.tsx +++ b/x-pack/plugins/canvas/public/components/var_config/index.tsx @@ -7,27 +7,19 @@ import React, { FC } from 'react'; import copy from 'copy-to-clipboard'; import { VarConfig as ChildComponent } from './var_config'; -import { - withKibana, - KibanaReactContextValue, - KibanaServices, -} from '../../../../../../src/plugins/kibana_react/public'; -import { CanvasServices } from '../../services'; - +import { useNotifyService } from '../../services'; import { ComponentStrings } from '../../../i18n'; - import { CanvasVariable } from '../../../types'; const { VarConfig: strings } = ComponentStrings; interface Props { - kibana: KibanaReactContextValue<{ canvas: CanvasServices } & KibanaServices>; - variables: CanvasVariable[]; setVariables: (variables: CanvasVariable[]) => void; } -const WrappedComponent: FC = ({ kibana, variables, setVariables }) => { +export const VarConfig: FC = ({ variables, setVariables }) => { + const { success } = useNotifyService(); const onDeleteVar = (v: CanvasVariable) => { const index = variables.findIndex((targetVar: CanvasVariable) => { return targetVar.name === v.name; @@ -36,15 +28,14 @@ const WrappedComponent: FC = ({ kibana, variables, setVariables }) => { const newVars = [...variables]; newVars.splice(index, 1); setVariables(newVars); - - kibana.services.canvas.notify.success(strings.getDeleteNotificationDescription()); + success(strings.getDeleteNotificationDescription()); } }; const onCopyVar = (v: CanvasVariable) => { const snippetStr = `{var "${v.name}"}`; copy(snippetStr, { debug: true }); - kibana.services.canvas.notify.success(strings.getCopyNotificationDescription()); + success(strings.getCopyNotificationDescription()); }; const onAddVar = (v: CanvasVariable) => { @@ -62,5 +53,3 @@ const WrappedComponent: FC = ({ kibana, variables, setVariables }) => { return ; }; - -export const VarConfig = withKibana(WrappedComponent); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.tsx b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.tsx index 13b2cace13a408..264873fc994ddc 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { connect } from 'react-redux'; import { compose, withProps } from 'recompose'; import { Dispatch } from 'redux'; -import { withKibana } from '../../../../../../../src/plugins/kibana_react/public/'; import { State, ElementSpec } from '../../../../types'; // @ts-expect-error untyped local import { elementsRegistry } from '../../../lib/elements_registry'; @@ -44,6 +43,5 @@ const mergeProps = (stateProps: StateProps, dispatchProps: DispatchProps) => ({ export const ElementMenu = compose( connect(mapStateToProps, mapDispatchToProps, mergeProps), - withKibana, withProps(() => ({ elements: elementsRegistry.toJS() })) )(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/index.ts index 17fcc50334a8f4..01bcfebc0dba90 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/index.ts @@ -13,8 +13,7 @@ import { downloadWorkpad } from '../../../lib/download_workpad'; import { ShareMenu as Component, Props as ComponentProps } from './share_menu'; import { getPdfUrl, createPdf } from './utils'; import { State, CanvasWorkpad } from '../../../../types'; -import { withKibana } from '../../../../../../../src/plugins/kibana_react/public/'; -import { WithKibanaProps } from '../../../index'; +import { withServices, WithServicesProps } from '../../../services'; import { ComponentStrings } from '../../../../i18n'; @@ -43,12 +42,16 @@ interface Props { export const ShareMenu = compose( connect(mapStateToProps), - withKibana, + withServices, withProps( - ({ workpad, pageCount, kibana }: Props & WithKibanaProps): ComponentProps => ({ + ({ workpad, pageCount, services }: Props & WithServicesProps): ComponentProps => ({ getExportUrl: (type) => { if (type === 'pdf') { - const pdfUrl = getPdfUrl(workpad, { pageCount }, kibana.services.http.basePath); + const pdfUrl = getPdfUrl( + workpad, + { pageCount }, + services.platform.getBasePathInterface() + ); return getAbsoluteUrl(pdfUrl); } @@ -57,10 +60,10 @@ export const ShareMenu = compose( onCopy: (type) => { switch (type) { case 'pdf': - kibana.services.canvas.notify.info(strings.getCopyPDFMessage()); + services.notify.info(strings.getCopyPDFMessage()); break; case 'reportingConfig': - kibana.services.canvas.notify.info(strings.getCopyReportingConfigMessage()); + services.notify.info(strings.getCopyReportingConfigMessage()); break; default: throw new Error(strings.getUnknownExportErrorMessage(type)); @@ -69,9 +72,9 @@ export const ShareMenu = compose( onExport: (type) => { switch (type) { case 'pdf': - return createPdf(workpad, { pageCount }, kibana.services.http.basePath) + return createPdf(workpad, { pageCount }, services.platform.getBasePathInterface()) .then(({ data }: { data: { job: { id: string } } }) => { - kibana.services.canvas.notify.info(strings.getExportPDFMessage(), { + services.notify.info(strings.getExportPDFMessage(), { title: strings.getExportPDFTitle(workpad.name), }); @@ -79,7 +82,7 @@ export const ShareMenu = compose( jobCompletionNotifications.add(data.job.id); }) .catch((err: Error) => { - kibana.services.canvas.notify.error(err, { + services.notify.error(err, { title: strings.getExportPDFErrorTitle(workpad.name), }); }); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts index ddf1a12775cae1..e2a05d13b017eb 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts @@ -7,7 +7,6 @@ import { connect } from 'react-redux'; import { compose, withHandlers } from 'recompose'; import { Dispatch } from 'redux'; -import { withKibana } from '../../../../../../../src/plugins/kibana_react/public/'; import { zoomHandlerCreators } from '../../../lib/app_handler_creators'; import { State, CanvasWorkpadBoundingBox } from '../../../../types'; // @ts-expect-error untyped local @@ -97,6 +96,5 @@ const mergeProps = ( export const ViewMenu = compose( connect(mapStateToProps, mapDispatchToProps, mergeProps), - withKibana, withHandlers(zoomHandlerCreators) )(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/index.js b/x-pack/plugins/canvas/public/components/workpad_loader/index.js index ab07d5d7224053..f747cb677a5768 100644 --- a/x-pack/plugins/canvas/public/components/workpad_loader/index.js +++ b/x-pack/plugins/canvas/public/components/workpad_loader/index.js @@ -14,7 +14,7 @@ import { getWorkpad } from '../../state/selectors/workpad'; import { getId } from '../../lib/get_id'; import { downloadWorkpad } from '../../lib/download_workpad'; import { ComponentStrings, ErrorStrings } from '../../../i18n'; -import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { withServices } from '../../services'; import { WorkpadLoader as Component } from './workpad_loader'; const { WorkpadLoader: strings } = ComponentStrings; @@ -31,11 +31,11 @@ export const WorkpadLoader = compose( }), connect(mapStateToProps), withState('workpads', 'setWorkpads', null), - withKibana, - withProps(({ kibana }) => ({ - notify: kibana.services.canvas.notify, + withServices, + withProps(({ services }) => ({ + notify: services.notify, })), - withHandlers(({ kibana }) => ({ + withHandlers(({ services }) => ({ // Workpad creation via navigation createWorkpad: (props) => async (workpad) => { // workpad data uploaded, create and load it @@ -44,7 +44,7 @@ export const WorkpadLoader = compose( await workpadService.create(workpad); props.router.navigateTo('loadWorkpad', { id: workpad.id, page: 1 }); } catch (err) { - kibana.services.canvas.notify.error(err, { + services.notify.error(err, { title: errors.getUploadFailureErrorMessage(), }); } @@ -60,7 +60,7 @@ export const WorkpadLoader = compose( const workpads = await workpadService.find(text); setWorkpads(workpads); } catch (err) { - kibana.services.canvas.notify.error(err, { title: errors.getFindFailureErrorMessage() }); + services.notify.error(err, { title: errors.getFindFailureErrorMessage() }); } }, @@ -76,7 +76,7 @@ export const WorkpadLoader = compose( await workpadService.create(workpad); props.router.navigateTo('loadWorkpad', { id: workpad.id, page: 1 }); } catch (err) { - kibana.services.canvas.notify.error(err, { title: errors.getCloneFailureErrorMessage() }); + services.notify.error(err, { title: errors.getCloneFailureErrorMessage() }); } }, @@ -122,7 +122,7 @@ export const WorkpadLoader = compose( }; if (errored.length > 0) { - kibana.services.canvas.notify.error(errors.getDeleteFailureErrorMessage()); + services.notify.error(errors.getDeleteFailureErrorMessage()); } setWorkpads(workpadState); @@ -137,7 +137,7 @@ export const WorkpadLoader = compose( })), withProps((props) => ({ formatDate: (date) => { - const dateFormat = props.kibana.services.uiSettings.get('dateFormat'); + const dateFormat = props.services.platform.getUISetting('dateFormat'); return date && moment(date).format(dateFormat); }, })) diff --git a/x-pack/plugins/canvas/public/components/workpad_templates/index.tsx b/x-pack/plugins/canvas/public/components/workpad_templates/index.tsx index f35bba3fd598dd..35b0e2bb19e3e2 100644 --- a/x-pack/plugins/canvas/public/components/workpad_templates/index.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_templates/index.tsx @@ -10,12 +10,11 @@ import { RouterContext } from '../router'; import { ComponentStrings } from '../../../i18n/components'; // @ts-expect-error import * as workpadService from '../../lib/workpad_service'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { WorkpadTemplates as Component } from './workpad_templates'; import { CanvasTemplate } from '../../../types'; -import { UseKibanaProps } from '../../'; import { list } from '../../lib/template_service'; import { applyTemplateStrings } from '../../../i18n/templates/apply_strings'; +import { useNotifyService } from '../../services'; interface WorkpadTemplatesProps { onClose: () => void; @@ -33,7 +32,7 @@ export const WorkpadTemplates: FunctionComponent = ({ onC const [creatingFromTemplateName, setCreatingFromTemplateName] = useState( undefined ); - const kibana = useKibana(); + const { error } = useNotifyService(); useEffect(() => { if (!templates) { @@ -60,9 +59,9 @@ export const WorkpadTemplates: FunctionComponent = ({ onC if (router) { router.navigateTo('loadWorkpad', { id: result.data.id, page: 1 }); } - } catch (error) { + } catch (e) { setCreatingFromTemplateName(undefined); - kibana.services.canvas.notify.error(error, { + error(e, { title: `Couldn't create workpad from template`, }); } diff --git a/x-pack/plugins/canvas/public/lib/breadcrumbs.ts b/x-pack/plugins/canvas/public/lib/breadcrumbs.ts index 96412ef50c79de..b613bb7fcdaf15 100644 --- a/x-pack/plugins/canvas/public/lib/breadcrumbs.ts +++ b/x-pack/plugins/canvas/public/lib/breadcrumbs.ts @@ -24,6 +24,5 @@ export const getWorkpadBreadcrumb = ({ }; export const setBreadcrumb = (paths: ChromeBreadcrumb | ChromeBreadcrumb[]) => { - const setBreadCrumbs = platformService.getService().coreStart.chrome.setBreadcrumbs; - setBreadCrumbs(Array.isArray(paths) ? paths : [paths]); + platformService.getService().setBreadcrumbs(Array.isArray(paths) ? paths : [paths]); }; diff --git a/x-pack/plugins/canvas/public/lib/custom_element_service.ts b/x-pack/plugins/canvas/public/lib/custom_element_service.ts index 25c3b78a2746ec..f240df93d03870 100644 --- a/x-pack/plugins/canvas/public/lib/custom_element_service.ts +++ b/x-pack/plugins/canvas/public/lib/custom_element_service.ts @@ -11,7 +11,7 @@ import { CustomElement } from '../../types'; import { platformService } from '../services'; const getApiPath = function () { - const basePath = platformService.getService().coreStart.http.basePath.get(); + const basePath = platformService.getService().getBasePath(); return `${basePath}${API_ROUTE_CUSTOM_ELEMENT}`; }; diff --git a/x-pack/plugins/canvas/public/lib/documentation_links.ts b/x-pack/plugins/canvas/public/lib/documentation_links.ts index 6430f7d87d4f76..cb19389291028d 100644 --- a/x-pack/plugins/canvas/public/lib/documentation_links.ts +++ b/x-pack/plugins/canvas/public/lib/documentation_links.ts @@ -7,10 +7,14 @@ import { platformService } from '../services'; export const getDocumentationLinks = () => ({ - canvas: `${platformService.getService().coreStart.docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${ - platformService.getService().coreStart.docLinks.DOC_LINK_VERSION - }/canvas.html`, - numeral: `${platformService.getService().coreStart.docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${ - platformService.getService().coreStart.docLinks.DOC_LINK_VERSION - }/guide/numeral.html`, + canvas: `${platformService + .getService() + .getElasticWebsiteUrl()}guide/en/kibana/${platformService + .getService() + .getDocLinkVersion()}/canvas.html`, + numeral: `${platformService + .getService() + .getElasticWebsiteUrl()}guide/en/kibana/${platformService + .getService() + .getDocLinkVersion()}/guide/numeral.html`, }); diff --git a/x-pack/plugins/canvas/public/lib/es_service.ts b/x-pack/plugins/canvas/public/lib/es_service.ts index 5c1131d5fbe351..fee66c71636c8e 100644 --- a/x-pack/plugins/canvas/public/lib/es_service.ts +++ b/x-pack/plugins/canvas/public/lib/es_service.ts @@ -15,16 +15,16 @@ import { platformService } from '../services'; const { esService: strings } = ErrorStrings; const getApiPath = function () { - const basePath = platformService.getService().coreStart.http.basePath.get(); + const basePath = platformService.getService().getBasePath(); return basePath + API_ROUTE; }; const getSavedObjectsClient = function () { - return platformService.getService().coreStart.savedObjects.client; + return platformService.getService().getSavedObjectsClient(); }; const getAdvancedSettings = function () { - return platformService.getService().coreStart.uiSettings; + return platformService.getService().getUISettings(); }; export const getFields = (index = '_all') => { diff --git a/x-pack/plugins/canvas/public/lib/template_service.ts b/x-pack/plugins/canvas/public/lib/template_service.ts index 98d582c854e367..185b2ec37ba951 100644 --- a/x-pack/plugins/canvas/public/lib/template_service.ts +++ b/x-pack/plugins/canvas/public/lib/template_service.ts @@ -10,7 +10,7 @@ import { platformService } from '../services'; import { CanvasTemplate } from '../../types'; const getApiPath = function () { - const basePath = platformService.getService().coreStart.http.basePath.get(); + const basePath = platformService.getService().getBasePath(); return `${basePath}${API_ROUTE_TEMPLATES}`; }; diff --git a/x-pack/plugins/canvas/public/lib/workpad_service.js b/x-pack/plugins/canvas/public/lib/workpad_service.js index 2047e20424accb..27efe25405fd70 100644 --- a/x-pack/plugins/canvas/public/lib/workpad_service.js +++ b/x-pack/plugins/canvas/public/lib/workpad_service.js @@ -12,6 +12,7 @@ import { } from '../../common/lib/constants'; import { fetch } from '../../common/lib/fetch'; import { platformService } from '../services'; + /* Remove any top level keys from the workpad which will be rejected by validation */ @@ -44,17 +45,17 @@ const sanitizeWorkpad = function (workpad) { }; const getApiPath = function () { - const basePath = platformService.getService().coreStart.http.basePath.get(); + const basePath = platformService.getService().getBasePath(); return `${basePath}${API_ROUTE_WORKPAD}`; }; const getApiPathStructures = function () { - const basePath = platformService.getService().coreStart.http.basePath.get(); + const basePath = platformService.getService().getBasePath(); return `${basePath}${API_ROUTE_WORKPAD_STRUCTURES}`; }; const getApiPathAssets = function () { - const basePath = platformService.getService().coreStart.http.basePath.get(); + const basePath = platformService.getService().getBasePath(); return `${basePath}${API_ROUTE_WORKPAD_ASSETS}`; }; diff --git a/x-pack/plugins/canvas/public/services/context.tsx b/x-pack/plugins/canvas/public/services/context.tsx new file mode 100644 index 00000000000000..9bd86ef98f1e34 --- /dev/null +++ b/x-pack/plugins/canvas/public/services/context.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { + useContext, + createElement, + createContext, + ComponentType, + FC, + ReactElement, +} from 'react'; +import { CanvasServices, CanvasServiceProviders } from '.'; + +export interface WithServicesProps { + services: CanvasServices; +} + +const defaultContextValue = { + embeddables: {}, + expressions: {}, + notify: {}, + platform: {}, + navLink: {}, +}; + +const context = createContext(defaultContextValue as CanvasServices); + +export const useServices = () => useContext(context); +export const usePlatformService = () => useServices().platform; +export const useEmbeddablesService = () => useServices().embeddables; +export const useExpressionsService = () => useServices().expressions; +export const useNotifyService = () => useServices().notify; +export const useNavLinkService = () => useServices().navLink; + +export const withServices = (type: ComponentType) => { + const EnhancedType: FC = (props) => { + const services = useServices(); + return createElement(type, { ...props, services }); + }; + return EnhancedType; +}; + +export const ServicesProvider: FC<{ + providers: CanvasServiceProviders; + children: ReactElement; +}> = ({ providers, children }) => { + const value = { + embeddables: providers.embeddables.getService(), + expressions: providers.expressions.getService(), + notify: providers.notify.getService(), + platform: providers.platform.getService(), + navLink: providers.navLink.getService(), + }; + return {children}; +}; diff --git a/x-pack/plugins/canvas/public/services/embeddables.ts b/x-pack/plugins/canvas/public/services/embeddables.ts new file mode 100644 index 00000000000000..13e308effcdba3 --- /dev/null +++ b/x-pack/plugins/canvas/public/services/embeddables.ts @@ -0,0 +1,21 @@ +/* + * 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 { EmbeddableFactory } from '../../../../../src/plugins/embeddable/public'; +import { CanvasServiceFactory } from '.'; + +export interface EmbeddablesService { + getEmbeddableFactories: () => IterableIterator; +} + +export const embeddablesServiceFactory: CanvasServiceFactory = async ( + _coreSetup, + _coreStart, + _setupPlugins, + startPlugins +) => ({ + getEmbeddableFactories: startPlugins.embeddable.getEmbeddableFactories, +}); diff --git a/x-pack/plugins/canvas/public/services/expressions.ts b/x-pack/plugins/canvas/public/services/expressions.ts index 16f939a9c97fc2..1376aab0ca8b99 100644 --- a/x-pack/plugins/canvas/public/services/expressions.ts +++ b/x-pack/plugins/canvas/public/services/expressions.ts @@ -14,6 +14,5 @@ export const expressionsServiceFactory: CanvasServiceFactory startPlugins ) => { await setupPlugins.expressions.__LEGACY.loadLegacyServerFunctionWrappers(); - return setupPlugins.expressions.fork(); }; diff --git a/x-pack/plugins/canvas/public/services/index.ts b/x-pack/plugins/canvas/public/services/index.ts index a929b4639d3e47..700d874d4507d8 100644 --- a/x-pack/plugins/canvas/public/services/index.ts +++ b/x-pack/plugins/canvas/public/services/index.ts @@ -10,8 +10,16 @@ import { CanvasSetupDeps, CanvasStartDeps } from '../plugin'; import { notifyServiceFactory } from './notify'; import { platformServiceFactory } from './platform'; import { navLinkServiceFactory } from './nav_link'; +import { embeddablesServiceFactory } from './embeddables'; import { expressionsServiceFactory } from './expressions'; +export { NotifyService } from './notify'; +export { PlatformService } from './platform'; +export { NavLinkService } from './nav_link'; +export { EmbeddablesService } from './embeddables'; +export { ExpressionsService } from '../../../../../src/plugins/expressions/common'; +export * from './context'; + export type CanvasServiceFactory = ( coreSetup: CoreSetup, coreStart: CoreStart, @@ -28,6 +36,10 @@ class CanvasServiceProvider { this.factory = factory; } + setService(service: Service) { + this.service = service; + } + async start( coreSetup: CoreSetup, coreStart: CoreStart, @@ -60,13 +72,17 @@ class CanvasServiceProvider { export type ServiceFromProvider

= P extends CanvasServiceProvider ? T : never; export const services = { + embeddables: new CanvasServiceProvider(embeddablesServiceFactory), expressions: new CanvasServiceProvider(expressionsServiceFactory), notify: new CanvasServiceProvider(notifyServiceFactory), platform: new CanvasServiceProvider(platformServiceFactory), navLink: new CanvasServiceProvider(navLinkServiceFactory), }; +export type CanvasServiceProviders = typeof services; + export interface CanvasServices { + embeddables: ServiceFromProvider; expressions: ServiceFromProvider; notify: ServiceFromProvider; platform: ServiceFromProvider; @@ -88,10 +104,11 @@ export const startServices = async ( }; export const stopServices = () => { - Object.entries(services).forEach(([key, provider]) => provider.stop()); + Object.values(services).forEach((provider) => provider.stop()); }; export const { + embeddables: embeddableService, notify: notifyService, platform: platformService, navLink: navLinkService, diff --git a/x-pack/plugins/canvas/public/services/nav_link.ts b/x-pack/plugins/canvas/public/services/nav_link.ts index 68d685242351b2..532b5264ee9eda 100644 --- a/x-pack/plugins/canvas/public/services/nav_link.ts +++ b/x-pack/plugins/canvas/public/services/nav_link.ts @@ -8,15 +8,15 @@ import { CanvasServiceFactory } from '.'; import { SESSIONSTORAGE_LASTPATH } from '../../common/lib/constants'; import { getSessionStorage } from '../lib/storage'; -interface NavLinkService { +export interface NavLinkService { updatePath: (path: string) => void; } export const navLinkServiceFactory: CanvasServiceFactory = ( coreSetup, - coreStart, - setupPlugins, - startPlugins, + _coreStart, + _setupPlugins, + _startPlugins, appUpdater ) => { return { diff --git a/x-pack/plugins/canvas/public/services/notify.ts b/x-pack/plugins/canvas/public/services/notify.ts index 5454a0f87c3f06..819525c8fa9221 100644 --- a/x-pack/plugins/canvas/public/services/notify.ts +++ b/x-pack/plugins/canvas/public/services/notify.ts @@ -26,7 +26,7 @@ const getToast = (err: Error | string, opts: ToastInputFields = {}) => { }; }; -interface NotifyService { +export interface NotifyService { error: (err: string | Error, opts?: ToastInputFields) => void; warning: (err: string | Error, opts?: ToastInputFields) => void; info: (err: string | Error, opts?: ToastInputFields) => void; diff --git a/x-pack/plugins/canvas/public/services/platform.ts b/x-pack/plugins/canvas/public/services/platform.ts index 440e9523044c1c..92c378e9aa597e 100644 --- a/x-pack/plugins/canvas/public/services/platform.ts +++ b/x-pack/plugins/canvas/public/services/platform.ts @@ -4,21 +4,52 @@ * you may not use this file except in compliance with the Elastic License. */ +import { + SavedObjectsStart, + SavedObjectsClientContract, + IUiSettingsClient, + ChromeBreadcrumb, + IBasePath, +} from '../../../../../src/core/public'; import { CanvasServiceFactory } from '.'; -import { CoreStart, CoreSetup, CanvasSetupDeps, CanvasStartDeps } from '../plugin'; -interface PlatformService { - coreSetup: CoreSetup; - coreStart: CoreStart; - setupPlugins: CanvasSetupDeps; - startPlugins: CanvasStartDeps; +export interface PlatformService { + getBasePath: () => string; + getBasePathInterface: () => IBasePath; + getDocLinkVersion: () => string; + getElasticWebsiteUrl: () => string; + getHasWriteAccess: () => boolean; + getUISetting: (key: string, defaultValue?: any) => any; + setBreadcrumbs: (newBreadcrumbs: ChromeBreadcrumb[]) => void; + setRecentlyAccessed: (link: string, label: string, id: string) => void; + + // TODO: these should go away. We want thin accessors, not entire objects. + // Entire objects are hard to mock, and hide our dependency on the external service. + getSavedObjects: () => SavedObjectsStart; + getSavedObjectsClient: () => SavedObjectsClientContract; + getUISettings: () => IUiSettingsClient; } export const platformServiceFactory: CanvasServiceFactory = ( - coreSetup, - coreStart, - setupPlugins, - startPlugins + _coreSetup, + coreStart ) => { - return { coreSetup, coreStart, setupPlugins, startPlugins }; + return { + getBasePath: coreStart.http.basePath.get, + getBasePathInterface: () => coreStart.http.basePath, + getElasticWebsiteUrl: () => coreStart.docLinks.ELASTIC_WEBSITE_URL, + getDocLinkVersion: () => coreStart.docLinks.DOC_LINK_VERSION, + // TODO: is there a better type for this? The capabilities type allows for a Record, + // though we don't do this. So this cast may be the best option. + getHasWriteAccess: () => coreStart.application.capabilities.canvas.save as boolean, + getUISetting: coreStart.uiSettings.get.bind(coreStart.uiSettings), + setBreadcrumbs: coreStart.chrome.setBreadcrumbs, + setRecentlyAccessed: coreStart.chrome.recentlyAccessed.add, + + // TODO: these should go away. We want thin accessors, not entire objects. + // Entire objects are hard to mock, and hide our dependency on the external service. + getSavedObjects: () => coreStart.savedObjects, + getSavedObjectsClient: () => coreStart.savedObjects.client, + getUISettings: () => coreStart.uiSettings, + }; }; diff --git a/x-pack/plugins/canvas/public/services/stubs/embeddables.ts b/x-pack/plugins/canvas/public/services/stubs/embeddables.ts new file mode 100644 index 00000000000000..48100da462dd54 --- /dev/null +++ b/x-pack/plugins/canvas/public/services/stubs/embeddables.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EmbeddablesService } from '../embeddables'; + +const noop = (..._args: any[]): any => {}; + +export const embeddablesService: EmbeddablesService = { + getEmbeddableFactories: noop, +}; diff --git a/x-pack/plugins/canvas/public/services/stubs/expressions.ts b/x-pack/plugins/canvas/public/services/stubs/expressions.ts new file mode 100644 index 00000000000000..26a90670106d0a --- /dev/null +++ b/x-pack/plugins/canvas/public/services/stubs/expressions.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExpressionsService } from '../'; +import { + plugin, + ExpressionRenderDefinition, +} from '../../../../../../src/plugins/expressions/public'; +import { functions as functionDefinitions } from '../../../canvas_plugin_src/functions/common'; +// @ts-expect-error untyped local +import { renderFunctions } from '../../../canvas_plugin_src/renderers/core'; + +const placeholder = {} as any; +const expressionsPlugin = plugin(placeholder); +const setup = expressionsPlugin.setup(placeholder, { + inspector: {}, +} as any); + +export const expressionsService: ExpressionsService = setup.fork(); + +functionDefinitions.forEach((fn) => expressionsService.registerFunction(fn)); +renderFunctions.forEach((fn: ExpressionRenderDefinition) => + expressionsService.registerRenderer(fn) +); diff --git a/x-pack/plugins/canvas/public/services/stubs/index.ts b/x-pack/plugins/canvas/public/services/stubs/index.ts new file mode 100644 index 00000000000000..b4e440f204cc75 --- /dev/null +++ b/x-pack/plugins/canvas/public/services/stubs/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CanvasServices, services } from '../'; +import { embeddablesService } from './embeddables'; +import { expressionsService } from './expressions'; +import { navLinkService } from './nav_link'; +import { notifyService } from './notify'; +import { platformService } from './platform'; + +export const stubs: CanvasServices = { + embeddables: embeddablesService, + expressions: expressionsService, + navLink: navLinkService, + notify: notifyService, + platform: platformService, +}; + +export const startServices = async (providedServices: Partial = {}) => { + Object.entries(services).forEach(([key, provider]) => { + // @ts-expect-error Object.entries isn't strongly typed + const stub = providedServices[key] || stubs[key]; + provider.setService(stub); + }); +}; diff --git a/x-pack/plugins/canvas/public/services/stubs/nav_link.ts b/x-pack/plugins/canvas/public/services/stubs/nav_link.ts new file mode 100644 index 00000000000000..3b40eeb3e84f2e --- /dev/null +++ b/x-pack/plugins/canvas/public/services/stubs/nav_link.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { NavLinkService } from '../nav_link'; + +const noop = (..._args: any[]): any => {}; + +export const navLinkService: NavLinkService = { + updatePath: noop, +}; diff --git a/x-pack/plugins/canvas/public/services/stubs/notify.ts b/x-pack/plugins/canvas/public/services/stubs/notify.ts new file mode 100644 index 00000000000000..38eac2a5813ebd --- /dev/null +++ b/x-pack/plugins/canvas/public/services/stubs/notify.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { NotifyService } from '../notify'; + +const noop = (..._args: any[]): any => {}; + +export const notifyService: NotifyService = { + error: noop, + info: noop, + success: noop, + warning: noop, +}; diff --git a/x-pack/plugins/canvas/public/services/stubs/platform.ts b/x-pack/plugins/canvas/public/services/stubs/platform.ts new file mode 100644 index 00000000000000..9ada579573502d --- /dev/null +++ b/x-pack/plugins/canvas/public/services/stubs/platform.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PlatformService } from '../platform'; + +const noop = (..._args: any[]): any => {}; + +export const platformService: PlatformService = { + getBasePath: () => '/base/path', + getBasePathInterface: noop, + getDocLinkVersion: () => 'dockLinkVersion', + getElasticWebsiteUrl: () => 'https://elastic.co', + getHasWriteAccess: () => true, + getUISetting: noop, + setBreadcrumbs: noop, + setRecentlyAccessed: noop, + getSavedObjects: noop, + getSavedObjectsClient: noop, + getUISettings: noop, +}; diff --git a/x-pack/plugins/canvas/public/state/initial_state.js b/x-pack/plugins/canvas/public/state/initial_state.js index 13021893e72e8c..f9b02d33d61126 100644 --- a/x-pack/plugins/canvas/public/state/initial_state.js +++ b/x-pack/plugins/canvas/public/state/initial_state.js @@ -9,11 +9,13 @@ import { platformService } from '../services'; import { getDefaultWorkpad } from './defaults'; export const getInitialState = (path) => { + const { getHasWriteAccess } = platformService.getService(); + const state = { app: {}, // Kibana stuff in here assets: {}, // assets end up here transient: { - canUserWrite: platformService.getService().coreStart.application.capabilities.canvas.save, + canUserWrite: getHasWriteAccess(), zoomScale: 1, elementStats: { total: 0, diff --git a/x-pack/plugins/canvas/public/state/reducers/workpad.js b/x-pack/plugins/canvas/public/state/reducers/workpad.js index 9a0c30bdf13374..fffcb69c451ed7 100644 --- a/x-pack/plugins/canvas/public/state/reducers/workpad.js +++ b/x-pack/plugins/canvas/public/state/reducers/workpad.js @@ -25,11 +25,7 @@ export const workpadReducer = handleActions( [setWorkpad]: (workpadState, { payload }) => { platformService .getService() - .coreStart.chrome.recentlyAccessed.add( - `${APP_ROUTE_WORKPAD}/${payload.id}`, - payload.name, - payload.id - ); + .setRecentlyAccessed(`${APP_ROUTE_WORKPAD}/${payload.id}`, payload.name, payload.id); return payload; }, diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.ts b/x-pack/plugins/canvas/public/state/selectors/workpad.ts index 1d7ea05daaa61c..a677bcaf29e61e 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.ts +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.ts @@ -497,7 +497,7 @@ export function getRenderedWorkpad(state: State) { const workpad = getWorkpad(state); // eslint-disable-next-line no-unused-vars - const { pages, ...rest } = workpad; + const { pages, variables, ...rest } = workpad; return { pages: renderedPages, diff --git a/x-pack/plugins/canvas/shareable_runtime/README.md b/x-pack/plugins/canvas/shareable_runtime/README.md index 8fdeb6ca6258e6..3839e7c4ecb3fd 100644 --- a/x-pack/plugins/canvas/shareable_runtime/README.md +++ b/x-pack/plugins/canvas/shareable_runtime/README.md @@ -207,7 +207,7 @@ There are a number of options for the build script: ### Prerequisite -Before testing or running this PR locally, you **must** run `node scripts/runtime` from `/canvas` _after_ `yarn kbn bootstrap` and _before_ starting Kibana. It is only built automatically when Kibana is built to avoid slowing down other development activities. +Before testing or running this PR locally, you **must** run `node scripts/shareable_runtime` from `/canvas` _after_ `yarn kbn bootstrap` and _before_ starting Kibana. It is only built automatically when Kibana is built to avoid slowing down other development activities. ### Webpack Dev Server diff --git a/x-pack/plugins/canvas/shareable_runtime/webpack.config.js b/x-pack/plugins/canvas/shareable_runtime/webpack.config.js index 1a5a21985ba72d..93dc3dbccd549e 100644 --- a/x-pack/plugins/canvas/shareable_runtime/webpack.config.js +++ b/x-pack/plugins/canvas/shareable_runtime/webpack.config.js @@ -55,7 +55,6 @@ module.exports = { options: { presets: [require.resolve('@kbn/babel-preset/webpack_preset')], }, - sideEffects: false, }, { test: /\.tsx?$/, @@ -92,6 +91,7 @@ module.exports = { }, }, ], + sideEffects: true, }, { test: /\.module\.s(a|c)ss$/, diff --git a/x-pack/plugins/canvas/storybook/config.js b/x-pack/plugins/canvas/storybook/config.js index f349f9b7ccf98f..dc16d6c46084d3 100644 --- a/x-pack/plugins/canvas/storybook/config.js +++ b/x-pack/plugins/canvas/storybook/config.js @@ -8,6 +8,7 @@ import { configure, addDecorator, addParameters } from '@storybook/react'; import { withInfo } from '@storybook/addon-info'; import { create } from '@storybook/theming'; +import { startServices } from '../public/services/stubs'; import { addDecorators } from './decorators'; // If we're running Storyshots, be sure to register the require context hook. @@ -32,6 +33,7 @@ if (process.env.NODE_ENV === 'test') { } addDecorators(); +startServices(); function loadStories() { require('./dll_contexts'); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts index 907c749f8ec0b0..12cf7ccac6c590 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts @@ -92,6 +92,17 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { ]); }; + const setSimulateTemplateResponse = (response?: HttpResponse, error?: any) => { + const status = error ? error.status || 400 : 200; + const body = error ? JSON.stringify(error.body) : JSON.stringify(response); + + server.respondWith('POST', `${API_BASE_PATH}/index_templates/simulate`, [ + status, + { 'Content-Type': 'application/json' }, + body, + ]); + }; + return { setLoadTemplatesResponse, setLoadIndicesResponse, @@ -102,6 +113,7 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { setLoadTemplateResponse, setCreateTemplateResponse, setUpdateTemplateResponse, + setSimulateTemplateResponse, }; }; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx index ad445f75f047c1..e40cdc026210d6 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx @@ -14,6 +14,8 @@ import { notificationServiceMock, docLinksServiceMock, } from '../../../../../../src/core/public/mocks'; +import { GlobalFlyout } from '../../../../../../src/plugins/es_ui_shared/public'; + import { AppContextProvider } from '../../../public/application/app_context'; import { httpService } from '../../../public/application/services/http'; import { breadcrumbService } from '../../../public/application/services/breadcrumbs'; @@ -23,9 +25,11 @@ import { ExtensionsService } from '../../../public/services'; import { UiMetricService } from '../../../public/application/services/ui_metric'; import { setUiMetricService } from '../../../public/application/services/api'; import { setExtensionsService } from '../../../public/application/store/selectors'; +import { MappingsEditorProvider } from '../../../public/application/components'; import { init as initHttpRequests } from './http_requests'; const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); +const { GlobalFlyoutProvider } = GlobalFlyout; export const services = { extensionsService: new ExtensionsService(), @@ -62,7 +66,11 @@ export const WithAppDependencies = (Comp: any, overridingDependencies: any = {}) const mergedDependencies = merge({}, appDependencies, overridingDependencies); return ( - + + + + + ); }; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts index 9889ebe16ba1e0..ecedf819e61851 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts @@ -28,6 +28,7 @@ export type TestSubjects = | 'legacyTemplateTable' | 'manageTemplateButton' | 'mappingsTabContent' + | 'previewTabContent' | 'noAliasesCallout' | 'noMappingsCallout' | 'noSettingsCallout' @@ -48,4 +49,5 @@ export type TestSubjects = | 'templateList' | 'templatesTab' | 'templateTable' - | 'viewButton'; + | 'viewButton' + | 'simulateTemplatePreview'; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts index a3974190533517..23b40f4cbd3d79 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts @@ -40,10 +40,15 @@ const createActions = (testBed: TestBed) => { /** * User Actions */ - const selectDetailsTab = (tab: 'summary' | 'settings' | 'mappings' | 'aliases') => { - const tabs = ['summary', 'settings', 'mappings', 'aliases']; + const selectDetailsTab = async ( + tab: 'summary' | 'settings' | 'mappings' | 'aliases' | 'preview' + ) => { + const tabs = ['summary', 'settings', 'mappings', 'aliases', 'preview']; - testBed.find('templateDetails.tab').at(tabs.indexOf(tab)).simulate('click'); + await act(async () => { + testBed.find('templateDetails.tab').at(tabs.indexOf(tab)).simulate('click'); + }); + testBed.component.update(); }; const clickReloadButton = () => { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts index f7ebc0bcf632b4..06f57896d4900c 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts @@ -493,7 +493,7 @@ describe('Index Templates tab', () => { }); describe('tabs', () => { - test('should have 4 tabs', async () => { + test('should have 5 tabs', async () => { const template = fixtures.getTemplate({ name: `a${getRandomString()}`, indexPatterns: ['template1Pattern1*', 'template1Pattern2'], @@ -524,35 +524,48 @@ describe('Index Templates tab', () => { const { find, actions, exists } = testBed; httpRequestsMockHelpers.setLoadTemplateResponse(template); + httpRequestsMockHelpers.setSimulateTemplateResponse({ simulateTemplate: 'response' }); await actions.clickTemplateAt(0); - expect(find('templateDetails.tab').length).toBe(4); + expect(find('templateDetails.tab').length).toBe(5); expect(find('templateDetails.tab').map((t) => t.text())).toEqual([ 'Summary', 'Settings', 'Mappings', 'Aliases', + 'Preview', ]); // Summary tab should be initial active tab expect(exists('summaryTab')).toBe(true); // Navigate and verify all tabs - actions.selectDetailsTab('settings'); + await actions.selectDetailsTab('settings'); expect(exists('summaryTab')).toBe(false); expect(exists('settingsTabContent')).toBe(true); - actions.selectDetailsTab('aliases'); + await actions.selectDetailsTab('aliases'); expect(exists('summaryTab')).toBe(false); expect(exists('settingsTabContent')).toBe(false); expect(exists('aliasesTabContent')).toBe(true); - actions.selectDetailsTab('mappings'); + await actions.selectDetailsTab('mappings'); expect(exists('summaryTab')).toBe(false); expect(exists('settingsTabContent')).toBe(false); expect(exists('aliasesTabContent')).toBe(false); expect(exists('mappingsTabContent')).toBe(true); + + await actions.selectDetailsTab('preview'); + expect(exists('summaryTab')).toBe(false); + expect(exists('settingsTabContent')).toBe(false); + expect(exists('aliasesTabContent')).toBe(false); + expect(exists('mappingsTabContent')).toBe(false); + expect(exists('previewTabContent')).toBe(true); + + expect(find('simulateTemplatePreview').text().replace(/\s/g, '')).toEqual( + JSON.stringify({ simulateTemplate: 'response' }) + ); }); test('should show an info callout if data is not present', async () => { @@ -568,17 +581,17 @@ describe('Index Templates tab', () => { await actions.clickTemplateAt(0); - expect(find('templateDetails.tab').length).toBe(4); + expect(find('templateDetails.tab').length).toBe(5); expect(exists('summaryTab')).toBe(true); // Navigate and verify callout message per tab - actions.selectDetailsTab('settings'); + await actions.selectDetailsTab('settings'); expect(exists('noSettingsCallout')).toBe(true); - actions.selectDetailsTab('mappings'); + await actions.selectDetailsTab('mappings'); expect(exists('noMappingsCallout')).toBe(true); - actions.selectDetailsTab('aliases'); + await actions.selectDetailsTab('aliases'); expect(exists('noAliasesCallout')).toBe(true); }); }); diff --git a/x-pack/plugins/index_management/common/constants/index.ts b/x-pack/plugins/index_management/common/constants/index.ts index d1700f0e611c05..11240271503e2a 100644 --- a/x-pack/plugins/index_management/common/constants/index.ts +++ b/x-pack/plugins/index_management/common/constants/index.ts @@ -47,7 +47,9 @@ export { UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB, UIM_TEMPLATE_DETAIL_PANEL_MAPPINGS_TAB, UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB, + UIM_TEMPLATE_DETAIL_PANEL_PREVIEW_TAB, UIM_TEMPLATE_CREATE, UIM_TEMPLATE_UPDATE, UIM_TEMPLATE_CLONE, + UIM_TEMPLATE_SIMULATE, } from './ui_metric'; diff --git a/x-pack/plugins/index_management/common/constants/ui_metric.ts b/x-pack/plugins/index_management/common/constants/ui_metric.ts index 5fda812c704d16..545555b92f3520 100644 --- a/x-pack/plugins/index_management/common/constants/ui_metric.ts +++ b/x-pack/plugins/index_management/common/constants/ui_metric.ts @@ -41,6 +41,8 @@ export const UIM_TEMPLATE_DETAIL_PANEL_SUMMARY_TAB = 'template_details_summary_t export const UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB = 'template_details_settings_tab'; export const UIM_TEMPLATE_DETAIL_PANEL_MAPPINGS_TAB = 'template_details_mappings_tab'; export const UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB = 'template_details_aliases_tab'; +export const UIM_TEMPLATE_DETAIL_PANEL_PREVIEW_TAB = 'template_details_preview_tab'; export const UIM_TEMPLATE_CREATE = 'template_create'; export const UIM_TEMPLATE_UPDATE = 'template_update'; export const UIM_TEMPLATE_CLONE = 'template_clone'; +export const UIM_TEMPLATE_SIMULATE = 'template_simulate'; diff --git a/x-pack/plugins/index_management/common/lib/template_serialization.ts b/x-pack/plugins/index_management/common/lib/template_serialization.ts index 069d6ac29fbca7..1803d89a40016a 100644 --- a/x-pack/plugins/index_management/common/lib/template_serialization.ts +++ b/x-pack/plugins/index_management/common/lib/template_serialization.ts @@ -109,7 +109,7 @@ export function serializeLegacyTemplate(template: TemplateDeserialized): LegacyT version, order, indexPatterns, - template: { settings, aliases, mappings }, + template: { settings, aliases, mappings } = {}, } = template; return { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts index 3d496d68cc66ed..a112d73230b82b 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts @@ -61,11 +61,10 @@ describe('', () => { const { exists, find, actions, component } = testBed; // Verify flyout exists with correct title - expect(exists('componentTemplateDetails')).toBe(true); - expect(find('componentTemplateDetails.title').text()).toBe(COMPONENT_TEMPLATE.name); + expect(find('title').text()).toBe(COMPONENT_TEMPLATE.name); // Verify footer does not display since "actions" prop was not provided - expect(exists('componentTemplateDetails.footer')).toBe(false); + expect(exists('footer')).toBe(false); // Verify tabs exist expect(exists('settingsTab')).toBe(true); @@ -185,7 +184,7 @@ describe('', () => { const { exists, actions, component, find } = testBed; // Verify footer exists - expect(exists('componentTemplateDetails.footer')).toBe(true); + expect(exists('footer')).toBe(true); expect(exists('manageComponentTemplateButton')).toBe(true); // Click manage button and verify actions diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts index 25c2d654fd9000..fe81e8dcfe1233 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts @@ -6,7 +6,7 @@ import { registerTestBed, TestBed } from '../../../../../../../../../test_utils'; import { WithAppDependencies } from './setup_environment'; -import { ComponentTemplateDetailsFlyout } from '../../../component_template_details'; +import { ComponentTemplateDetailsFlyoutContent } from '../../../component_template_details'; export type ComponentTemplateDetailsTestBed = TestBed & { actions: ReturnType; @@ -44,7 +44,7 @@ const createActions = (testBed: TestBed) = export const setup = (props: any): ComponentTemplateDetailsTestBed => { const setupTestBed = registerTestBed( - WithAppDependencies(ComponentTemplateDetailsFlyout), + WithAppDependencies(ComponentTemplateDetailsFlyoutContent), { memoryRouter: { wrapComponent: false, @@ -65,6 +65,8 @@ export type ComponentTemplateDetailsTestSubjects = | 'componentTemplateDetails' | 'componentTemplateDetails.title' | 'componentTemplateDetails.footer' + | 'title' + | 'footer' | 'summaryTab' | 'mappingsTab' | 'settingsTab' diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx index 7e460d3855cb0d..2f7317e3e656b9 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx @@ -15,12 +15,15 @@ import { applicationServiceMock, } from '../../../../../../../../../../src/core/public/mocks'; +import { GlobalFlyout } from '../../../../../../../../../../src/plugins/es_ui_shared/public'; +import { MappingsEditorProvider } from '../../../../mappings_editor'; import { ComponentTemplatesProvider } from '../../../component_templates_context'; import { init as initHttpRequests } from './http_requests'; import { API_BASE_PATH } from './constants'; const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); +const { GlobalFlyoutProvider } = GlobalFlyout; const appDependencies = { httpClient: (mockHttpClient as unknown) as HttpSetup, @@ -42,7 +45,11 @@ export const setupEnvironment = () => { }; export const WithAppDependencies = (Comp: any) => (props: any) => ( - - - + + + + + + + ); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx index 60f1fff3cc9de8..0f5bc64c358b9e 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx @@ -8,7 +8,6 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody, @@ -28,14 +27,19 @@ import { ComponentTemplateTabs, TabType } from './tabs'; import { ManageButton, ManageAction } from './manage_button'; import { attemptToDecodeURI } from '../lib'; -interface Props { +export interface Props { componentTemplateName: string; onClose: () => void; actions?: ManageAction[]; showSummaryCallToAction?: boolean; } -export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({ +export const defaultFlyoutProps = { + 'data-test-subj': 'componentTemplateDetails', + 'aria-labelledby': 'componentTemplateDetailsFlyoutTitle', +}; + +export const ComponentTemplateDetailsFlyoutContent: React.FunctionComponent = ({ componentTemplateName, onClose, actions, @@ -109,13 +113,7 @@ export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({ } return ( - + <> @@ -172,6 +170,6 @@ export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({ )} - + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/index.ts index 11aac200a2f148..8687a1f5b89c0e 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/index.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/index.ts @@ -4,4 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ComponentTemplateDetailsFlyout } from './component_template_details'; +export { + ComponentTemplateDetailsFlyoutContent, + defaultFlyoutProps, + Props as ComponentTemplateDetailsProps, +} from './component_template_details'; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx index efc8b649ef872c..8ba7409a9ac575 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx @@ -4,18 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ScopedHistory } from 'kibana/public'; import { EuiLink, EuiText, EuiSpacer } from '@elastic/eui'; -import { SectionLoading, ComponentTemplateDeserialized } from '../shared_imports'; +import { SectionLoading, ComponentTemplateDeserialized, GlobalFlyout } from '../shared_imports'; import { UIM_COMPONENT_TEMPLATE_LIST_LOAD } from '../constants'; import { attemptToDecodeURI } from '../lib'; import { useComponentTemplatesContext } from '../component_templates_context'; -import { ComponentTemplateDetailsFlyout } from '../component_template_details'; +import { + ComponentTemplateDetailsFlyoutContent, + defaultFlyoutProps, + ComponentTemplateDetailsProps, +} from '../component_template_details'; import { EmptyPrompt } from './empty_prompt'; import { ComponentTable } from './table'; import { LoadError } from './error'; @@ -26,39 +30,112 @@ interface Props { history: RouteComponentProps['history']; } +const { useGlobalFlyout } = GlobalFlyout; + export const ComponentTemplateList: React.FunctionComponent = ({ componentTemplateName, history, }) => { + const { + addContent: addContentToGlobalFlyout, + removeContent: removeContentFromGlobalFlyout, + } = useGlobalFlyout(); const { api, trackMetric, documentation } = useComponentTemplatesContext(); const { data, isLoading, error, sendRequest } = api.useLoadComponentTemplates(); const [componentTemplatesToDelete, setComponentTemplatesToDelete] = useState([]); - const goToComponentTemplateList = () => { + const goToComponentTemplateList = useCallback(() => { return history.push({ pathname: 'component_templates', }); - }; - - const goToEditComponentTemplate = (name: string) => { - return history.push({ - pathname: encodeURI(`edit_component_template/${encodeURIComponent(name)}`), - }); - }; + }, [history]); + + const goToEditComponentTemplate = useCallback( + (name: string) => { + return history.push({ + pathname: encodeURI(`edit_component_template/${encodeURIComponent(name)}`), + }); + }, + [history] + ); - const goToCloneComponentTemplate = (name: string) => { - return history.push({ - pathname: encodeURI(`create_component_template/${encodeURIComponent(name)}`), - }); - }; + const goToCloneComponentTemplate = useCallback( + (name: string) => { + return history.push({ + pathname: encodeURI(`create_component_template/${encodeURIComponent(name)}`), + }); + }, + [history] + ); // Track component loaded useEffect(() => { trackMetric('loaded', UIM_COMPONENT_TEMPLATE_LIST_LOAD); }, [trackMetric]); + useEffect(() => { + if (componentTemplateName) { + const actions = [ + { + name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.editButtonLabel', { + defaultMessage: 'Edit', + }), + icon: 'pencil', + handleActionClick: () => + goToEditComponentTemplate(attemptToDecodeURI(componentTemplateName)), + }, + { + name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.cloneActionLabel', { + defaultMessage: 'Clone', + }), + icon: 'copy', + handleActionClick: () => + goToCloneComponentTemplate(attemptToDecodeURI(componentTemplateName)), + }, + { + name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.deleteButtonLabel', { + defaultMessage: 'Delete', + }), + icon: 'trash', + getIsDisabled: (details: ComponentTemplateDeserialized) => + details._kbnMeta.usedBy.length > 0, + closePopoverOnClick: true, + handleActionClick: () => { + setComponentTemplatesToDelete([attemptToDecodeURI(componentTemplateName)]); + }, + }, + ]; + + // Open the flyout with the Component Template Details content + addContentToGlobalFlyout({ + id: 'componentTemplateDetails', + Component: ComponentTemplateDetailsFlyoutContent, + props: { + onClose: goToComponentTemplateList, + componentTemplateName, + showSummaryCallToAction: true, + actions, + }, + flyoutProps: { ...defaultFlyoutProps, onClose: goToComponentTemplateList }, + }); + } + }, [ + componentTemplateName, + goToComponentTemplateList, + goToEditComponentTemplate, + goToCloneComponentTemplate, + addContentToGlobalFlyout, + history, + ]); + + useEffect(() => { + if (!componentTemplateName) { + removeContentFromGlobalFlyout('componentTemplateDetails'); + } + }, [componentTemplateName, removeContentFromGlobalFlyout]); + let content: React.ReactNode; if (isLoading) { @@ -126,45 +203,6 @@ export const ComponentTemplateList: React.FunctionComponent = ({ componentTemplatesToDelete={componentTemplatesToDelete} /> ) : null} - - {/* details flyout */} - {componentTemplateName && ( - - goToEditComponentTemplate(attemptToDecodeURI(componentTemplateName)), - }, - { - name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.cloneActionLabel', { - defaultMessage: 'Clone', - }), - icon: 'copy', - handleActionClick: () => - goToCloneComponentTemplate(attemptToDecodeURI(componentTemplateName)), - }, - { - name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.deleteButtonLabel', { - defaultMessage: 'Delete', - }), - icon: 'trash', - getIsDisabled: (details: ComponentTemplateDeserialized) => - details._kbnMeta.usedBy.length > 0, - closePopoverOnClick: true, - handleActionClick: () => { - setComponentTemplatesToDelete([attemptToDecodeURI(componentTemplateName)]); - }, - }, - ]} - /> - )} ); }; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx index 8795c08fd2bee8..ed570579d4e455 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx @@ -11,8 +11,12 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { ComponentTemplateListItem } from '../../../../../common'; -import { SectionError, SectionLoading } from '../shared_imports'; -import { ComponentTemplateDetailsFlyout } from '../component_template_details'; +import { SectionError, SectionLoading, GlobalFlyout } from '../shared_imports'; +import { + ComponentTemplateDetailsFlyoutContent, + defaultFlyoutProps, + ComponentTemplateDetailsProps, +} from '../component_template_details'; import { CreateButtonPopOver } from './components'; import { ComponentTemplates } from './component_templates'; import { ComponentTemplatesSelection } from './component_templates_selection'; @@ -20,10 +24,12 @@ import { useApi } from '../component_templates_context'; import './component_templates_selector.scss'; +const { useGlobalFlyout } = GlobalFlyout; + interface Props { onChange: (components: string[]) => void; onComponentsLoaded: (components: ComponentTemplateListItem[]) => void; - defaultValue: string[]; + defaultValue?: string[]; docUri: string; emptyPrompt?: { text?: string | JSX.Element; @@ -53,6 +59,10 @@ export const ComponentTemplatesSelector = ({ emptyPrompt: { text, showCreateButton } = {}, }: Props) => { const { data: components, isLoading, error } = useApi().useLoadComponentTemplates(); + const { + addContent: addContentToGlobalFlyout, + removeContent: removeContentFromGlobalFlyout, + } = useGlobalFlyout(); const [selectedComponent, setSelectedComponent] = useState(null); const [componentsSelected, setComponentsSelected] = useState([]); const isInitialized = useRef(false); @@ -60,15 +70,20 @@ export const ComponentTemplatesSelector = ({ const hasSelection = Object.keys(componentsSelected).length > 0; const hasComponents = components && components.length > 0 ? true : false; + const closeComponentTemplateDetails = () => { + setSelectedComponent(null); + }; + useEffect(() => { if (components) { if ( + defaultValue && defaultValue.length > 0 && componentsSelected.length === 0 && isInitialized.current === false ) { - // Once the components are loaded we check the ones selected - // from the defaultValue provided + // Once the components are fetched, we check the ones previously selected + // from the prop "defaultValue" passed. const nextComponentsSelected = defaultValue .map((name) => components.find((comp) => comp.name === name)) .filter(Boolean) as ComponentTemplateListItem[]; @@ -88,6 +103,30 @@ export const ComponentTemplatesSelector = ({ } }, [isLoading, error, components, onComponentsLoaded]); + useEffect(() => { + if (selectedComponent) { + // Open the flyout with the Component Template Details content + addContentToGlobalFlyout({ + id: 'componentTemplateDetails', + Component: ComponentTemplateDetailsFlyoutContent, + props: { + onClose: closeComponentTemplateDetails, + componentTemplateName: selectedComponent, + }, + flyoutProps: { ...defaultFlyoutProps, onClose: closeComponentTemplateDetails }, + cleanUpFunc: () => { + setSelectedComponent(null); + }, + }); + } + }, [selectedComponent, addContentToGlobalFlyout]); + + useEffect(() => { + if (!selectedComponent) { + removeContentFromGlobalFlyout('componentTemplateDetails'); + } + }, [selectedComponent, removeContentFromGlobalFlyout]); + const onSelectionReorder = (reorderedComponents: ComponentTemplateListItem[]) => { setComponentsSelected(reorderedComponents); }; @@ -198,30 +237,12 @@ export const ComponentTemplatesSelector = ({ ); - const renderComponentDetails = () => { - if (!selectedComponent) { - return null; - } - - return ( - setSelectedComponent(null)} - componentTemplateName={selectedComponent} - /> - ); - }; - if (isLoading) { return renderLoading(); } else if (error) { return renderError(); } else if (hasComponents) { - return ( - <> - {renderSelector()} - {renderComponentDetails()} - - ); + return renderSelector(); } // No components: render empty prompt @@ -244,6 +265,7 @@ export const ComponentTemplatesSelector = ({

); + return ( { + const [templatePreview, setTemplatePreview] = useState('{}'); + + const updatePreview = useCallback(async () => { + if (!template || Object.keys(template).length === 0) { + return; + } + + const indexTemplate = serializeTemplate(stripEmptyFields(template) as TemplateDeserialized); + + // Until ES fixes a bug on their side we will send a random index pattern to the simulate API. + // Issue: https://github.com/elastic/elasticsearch/issues/59152 + indexTemplate.index_patterns = [uuid.v4()]; + + const { data, error } = await simulateIndexTemplate(indexTemplate); + + if (data) { + // "Overlapping" info is only useful when simulating against an index + // which we don't do here. + delete data.overlapping; + } + + setTemplatePreview(JSON.stringify(data ?? error, null, 2)); + }, [template]); + + useEffect(() => { + updatePreview(); + }, [updatePreview]); + + return templatePreview === '{}' ? null : ( + + {templatePreview} + + ); +}); diff --git a/x-pack/plugins/index_management/public/application/components/index_templates/simulate_template/simulate_template_flyout.tsx b/x-pack/plugins/index_management/public/application/components/index_templates/simulate_template/simulate_template_flyout.tsx new file mode 100644 index 00000000000000..63bfe785460417 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/index_templates/simulate_template/simulate_template_flyout.tsx @@ -0,0 +1,119 @@ +/* + * 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, useEffect, useRef } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, + EuiTextColor, + EuiText, + EuiSpacer, +} from '@elastic/eui'; + +import { SimulateTemplate } from './simulate_template'; + +export interface Props { + onClose(): void; + getTemplate: () => { [key: string]: any }; +} + +export const defaultFlyoutProps = { + 'data-test-subj': 'simulateTemplateFlyout', + 'aria-labelledby': 'simulateTemplateFlyoutTitle', +}; + +export const SimulateTemplateFlyoutContent = ({ onClose, getTemplate }: Props) => { + const isMounted = useRef(false); + const [heightCodeBlock, setHeightCodeBlock] = useState(0); + const [template, setTemplate] = useState<{ [key: string]: any }>({}); + + useEffect(() => { + setHeightCodeBlock( + document.getElementsByClassName('euiFlyoutBody__overflow')[0].clientHeight - 96 + ); + }, []); + + const updatePreview = useCallback(async () => { + const indexTemplate = await getTemplate(); + setTemplate(indexTemplate); + }, [getTemplate]); + + useEffect(() => { + if (isMounted.current === false) { + updatePreview(); + } + isMounted.current = true; + }, [updatePreview]); + + return ( + <> + + +

+ +

+
+ + + +

+ +

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/shape_datatype.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/shape_datatype.test.tsx index 311cb37d0b47af..64347d19e9b475 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/shape_datatype.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/shape_datatype.test.tsx @@ -36,8 +36,6 @@ describe('Mappings editor: shape datatype', () => { test('initial view and default parameters values', async () => { const defaultMappings = { - _meta: {}, - _source: {}, properties: { myField: { type: 'shape', diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx index ed60414d198f14..c03aa4805d27f0 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx @@ -47,8 +47,6 @@ describe.skip('Mappings editor: text datatype', () => { test('initial view and default parameters values', async () => { const defaultMappings = { - _meta: {}, - _source: {}, properties: { myField: { type: 'text', diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx index 4f9d8a960a1a2f..c146c7704911fa 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx @@ -65,8 +65,6 @@ describe('Mappings editor: edit field', () => { test('should update form parameters when changing the field datatype', async () => { const defaultMappings = { - _meta: {}, - _source: {}, properties: { userName: { ...defaultTextParameters, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx index 638bbfd925ffbe..a6558b28a12734 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx @@ -7,9 +7,11 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { ReactWrapper } from 'enzyme'; +import { GlobalFlyout } from '../../../../../../../../../../src/plugins/es_ui_shared/public'; import { registerTestBed, TestBed } from '../../../../../../../../../test_utils'; import { getChildFieldsName } from '../../../lib'; import { MappingsEditor } from '../../../mappings_editor'; +import { MappingsEditorProvider } from '../../../mappings_editor_context'; jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); @@ -51,6 +53,8 @@ jest.mock('@elastic/eui', () => { }; }); +const { GlobalFlyoutProvider } = GlobalFlyout; + export interface DomFields { [key: string]: { type: string; @@ -247,7 +251,15 @@ const createActions = (testBed: TestBed) => { }; export const setup = (props: any = { onUpdate() {} }): MappingsEditorTestBed => { - const setupTestBed = registerTestBed(MappingsEditor, { + const ComponentToTest = (propsOverride: { [key: string]: any }) => ( + + + + + + ); + + const setupTestBed = registerTestBed(ComponentToTest, { memoryRouter: { wrapComponent: false, }, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx index 86bcc796a88ebd..20b2e11855029e 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx @@ -7,16 +7,14 @@ import React, { useEffect, useRef } from 'react'; import { EuiSpacer } from '@elastic/eui'; import { useForm, Form, SerializerFunc } from '../../shared_imports'; -import { GenericObject } from '../../types'; -import { Types, useDispatch } from '../../mappings_state'; +import { GenericObject, MappingsConfiguration } from '../../types'; +import { useDispatch } from '../../mappings_state_context'; import { DynamicMappingSection } from './dynamic_mapping_section'; import { SourceFieldSection } from './source_field_section'; import { MetaFieldSection } from './meta_field_section'; import { RoutingSection } from './routing_section'; import { configurationFormSchema } from './configuration_form_schema'; -type MappingsConfiguration = Types['MappingsConfiguration']; - interface Props { value?: MappingsConfiguration; } diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx index 6e80f8b813ec22..8742dfc9169248 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx @@ -11,8 +11,7 @@ import { EuiLink, EuiCode } from '@elastic/eui'; import { documentationService } from '../../../../services/documentation'; import { FormSchema, FIELD_TYPES, VALIDATION_TYPES, fieldValidators } from '../../shared_imports'; -import { MappingsConfiguration } from '../../reducer'; -import { ComboBoxOption } from '../../types'; +import { ComboBoxOption, MappingsConfiguration } from '../../types'; const { containsCharsField, isJsonField } = fieldValidators; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/document_fields.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/document_fields.tsx index 400de4052afa4b..4b19b6f7ae5c35 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/document_fields.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/document_fields.tsx @@ -6,7 +6,7 @@ import React, { useMemo, useCallback } from 'react'; import { EuiSpacer } from '@elastic/eui'; -import { useMappingsState, useDispatch } from '../../mappings_state'; +import { useMappingsState, useDispatch } from '../../mappings_state_context'; import { deNormalize } from '../../lib'; import { EditFieldContainer } from './fields'; import { DocumentFieldsHeader } from './document_fields_header'; @@ -18,7 +18,7 @@ export const DocumentFields = React.memo(() => { const { fields, search, documentFields } = useMappingsState(); const dispatch = useDispatch(); - const { status, fieldToEdit, editor: editorType } = documentFields; + const { editor: editorType } = documentFields; const jsonEditorDefaultValue = useMemo(() => { if (editorType === 'json') { @@ -33,14 +33,6 @@ export const DocumentFields = React.memo(() => { ); - const renderEditField = () => { - if (status !== 'editingField') { - return null; - } - const field = fields.byId[fieldToEdit!]; - return ; - }; - const onSearchChange = useCallback( (value: string) => { dispatch({ type: 'search:update', value }); @@ -59,7 +51,7 @@ export const DocumentFields = React.memo(() => { ) : ( editor )} - {renderEditField()} + ); }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/editor_toggle_controls.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/editor_toggle_controls.tsx index 51f9ca63be403d..ad283a3fe47bd5 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/editor_toggle_controls.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/editor_toggle_controls.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiButton, EuiText } from '@elastic/eui'; -import { useDispatch, useMappingsState } from '../../mappings_state'; +import { useDispatch, useMappingsState } from '../../mappings_state_context'; import { FieldsEditor } from '../../types'; import { canUseMappingsEditor, normalize } from '../../lib'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/name_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/name_parameter.tsx index 01cca7e249a236..0320f2ff51da3e 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/name_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/name_parameter.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { TextField, UseField, FieldConfig } from '../../../shared_imports'; import { validateUniqueName } from '../../../lib'; import { PARAMETERS_DEFINITION } from '../../../constants'; -import { useMappingsState } from '../../../mappings_state'; +import { useMappingsState } from '../../../mappings_state_context'; export const NameParameter = () => { const { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/type_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/type_parameter.tsx index 46e70bf8e56ba2..31ae37c82a43e4 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/type_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/type_parameter.tsx @@ -70,7 +70,13 @@ export const TypeParameter = ({ isMultiField, isRootLevelField, showDocLink = fa : filterTypesForNonRootFields(FIELD_TYPES_OPTIONS) } selectedOptions={typeField.value} - onChange={typeField.setValue} + onChange={(value) => { + if (value.length === 0) { + // Don't allow clearing the type. One must always be selected + return; + } + typeField.setValue(value); + }} isClearable={false} data-test-subj="fieldType" /> diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx index 57a765c38dd263..dc631b7dbf32dc 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx @@ -18,7 +18,7 @@ import { import { useForm, Form, FormDataProvider } from '../../../../shared_imports'; import { EUI_SIZE } from '../../../../constants'; -import { useDispatch } from '../../../../mappings_state'; +import { useDispatch } from '../../../../mappings_state_context'; import { fieldSerializer } from '../../../../lib'; import { Field, NormalizedFields } from '../../../../types'; import { NameParameter, TypeParameter, SubTypeParameter } from '../../field_parameters'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/delete_field_provider.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/delete_field_provider.tsx index 80e3e9bec605a1..2a98b5948e5a9c 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/delete_field_provider.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/delete_field_provider.tsx @@ -7,7 +7,7 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { useMappingsState, useDispatch } from '../../../mappings_state'; +import { useMappingsState, useDispatch } from '../../../mappings_state_context'; import { NormalizedField } from '../../../types'; import { getAllDescendantAliases } from '../../../lib'; import { ModalConfirmationDeleteFields } from './modal_confirmation_delete_fields'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx index e8e41955a5e808..e6950ccfe253e3 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx @@ -6,7 +6,6 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { - EuiFlyout, EuiFlyoutHeader, EuiFlyoutBody, EuiFlyoutFooter, @@ -25,7 +24,7 @@ import { TYPE_DEFINITION } from '../../../../constants'; import { Field, NormalizedField, NormalizedFields, MainType, SubType } from '../../../../types'; import { CodeBlock } from '../../../code_block'; import { getParametersFormForType } from '../field_types'; -import { UpdateFieldProvider, UpdateFieldFunc } from './update_field_provider'; +import { UpdateFieldFunc } from './use_update_field'; import { EditFieldHeaderForm } from './edit_field_header_form'; const limitStringLength = (text: string, limit = 18): string => { @@ -36,19 +35,28 @@ const limitStringLength = (text: string, limit = 18): string => { return `...${text.substr(limit * -1)}`; }; -interface Props { +export interface Props { form: FormHook; field: NormalizedField; allFields: NormalizedFields['byId']; exitEdit(): void; + updateField: UpdateFieldFunc; } -export const EditField = React.memo(({ form, field, allFields, exitEdit }: Props) => { - const getSubmitForm = (updateField: UpdateFieldFunc) => async (e?: React.FormEvent) => { - if (e) { - e.preventDefault(); - } +export const defaultFlyoutProps = { + 'data-test-subj': 'mappingsEditorFieldEdit', + 'aria-labelledby': 'mappingsEditorFieldEditTitle', + className: 'mappingsEditor__editField', + maxWidth: 720, +}; + +// The default FormWrapper is the , which wrapps the form with +// a
. We can't have a div as first child of the Flyout as it breaks +// the height calculaction and does not render the footer position correctly. +const FormWrapper: React.FC = ({ children }) => <>{children}; +export const EditField = React.memo(({ form, field, allFields, exitEdit, updateField }: Props) => { + const submitForm = async () => { const { isValid, data } = await form.submit(); if (isValid) { @@ -56,174 +64,152 @@ export const EditField = React.memo(({ form, field, allFields, exitEdit }: Props } }; - const cancel = () => { - exitEdit(); - }; - const { isMultiField } = field; return ( - - {(updateField) => ( -
- - - - - {/* We need an extra div to get out of flex grow */} -
- {/* Title */} - -

- {isMultiField - ? i18n.translate('xpack.idxMgmt.mappingsEditor.editMultiFieldTitle', { - defaultMessage: "Edit multi-field '{fieldName}'", - values: { - fieldName: limitStringLength(field.source.name), - }, - }) - : i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldTitle', { - defaultMessage: "Edit field '{fieldName}'", - values: { - fieldName: limitStringLength(field.source.name), - }, - })} -

-
-
-
- - {/* Documentation link */} - - {({ type, subType }) => { - const linkDocumentation = - documentationService.getTypeDocLink(subType) || - documentationService.getTypeDocLink(type); - - if (!linkDocumentation) { - return null; - } - - const typeDefinition = TYPE_DEFINITION[type as MainType]; - const subTypeDefinition = TYPE_DEFINITION[subType as SubType]; - - return ( - - - {i18n.translate( - 'xpack.idxMgmt.mappingsEditor.editField.typeDocumentation', - { - defaultMessage: '{type} documentation', - values: { - type: subTypeDefinition - ? subTypeDefinition.label - : typeDefinition.label, - }, - } - )} - - - ); - }} - -
- - {/* Field path */} - - - {field.path.join(' > ')} - - -
- - - - - - {({ type, subType }) => { - const ParametersForm = getParametersFormForType(type, subType); - - if (!ParametersForm) { - return null; - } - - return ( - - ); - }} - - - - - {form.isSubmitted && !form.isValid && ( - <> - - - - )} - - + + + + + {/* We need an extra div to get out of flex grow */} +
+ {/* Title */} + +

+ {isMultiField + ? i18n.translate('xpack.idxMgmt.mappingsEditor.editMultiFieldTitle', { + defaultMessage: "Edit multi-field '{fieldName}'", + values: { + fieldName: limitStringLength(field.source.name), + }, + }) + : i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldTitle', { + defaultMessage: "Edit field '{fieldName}'", + values: { + fieldName: limitStringLength(field.source.name), + }, + })} +

+
+
+
+ + {/* Documentation link */} + + {({ type, subType }) => { + const linkDocumentation = + documentationService.getTypeDocLink(subType) || + documentationService.getTypeDocLink(type); + + if (!linkDocumentation) { + return null; + } + + const typeDefinition = TYPE_DEFINITION[type as MainType]; + const subTypeDefinition = TYPE_DEFINITION[subType as SubType]; + + return ( - - {i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldCancelButtonLabel', { - defaultMessage: 'Cancel', - })} - - - - - {i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldUpdateButtonLabel', { - defaultMessage: 'Update', + {i18n.translate('xpack.idxMgmt.mappingsEditor.editField.typeDocumentation', { + defaultMessage: '{type} documentation', + values: { + type: subTypeDefinition ? subTypeDefinition.label : typeDefinition.label, + }, })} - + -
-
-
-
- )} -
+ ); + }} + + + + {/* Field path */} + + + {field.path.join(' > ')} + + + + + + + + + {({ type, subType }) => { + const ParametersForm = getParametersFormForType(type, subType); + + if (!ParametersForm) { + return null; + } + + return ( + + ); + }} + + + + + {form.isSubmitted && !form.isValid && ( + <> + + + + )} + + + + + {i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldCancelButtonLabel', { + defaultMessage: 'Cancel', + })} + + + + + {i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldUpdateButtonLabel', { + defaultMessage: 'Update', + })} + + + + + ); }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx index 5105a2a157a6d2..4996f59105c046 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx @@ -3,24 +3,38 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback, useMemo } from 'react'; -import { useForm } from '../../../../shared_imports'; -import { useDispatch } from '../../../../mappings_state'; -import { Field, NormalizedField, NormalizedFields } from '../../../../types'; +import { useForm, GlobalFlyout } from '../../../../shared_imports'; +import { useDispatch, useMappingsState } from '../../../../mappings_state_context'; +import { Field } from '../../../../types'; import { fieldSerializer, fieldDeserializer } from '../../../../lib'; -import { EditField } from './edit_field'; +import { ModalConfirmationDeleteFields } from '../modal_confirmation_delete_fields'; +import { EditField, defaultFlyoutProps, Props as EditFieldProps } from './edit_field'; +import { useUpdateField } from './use_update_field'; -interface Props { - field: NormalizedField; - allFields: NormalizedFields['byId']; -} +const { useGlobalFlyout } = GlobalFlyout; -export const EditFieldContainer = React.memo(({ field, allFields }: Props) => { +export const EditFieldContainer = React.memo(() => { + const { fields, documentFields } = useMappingsState(); const dispatch = useDispatch(); + const { + addContent: addContentToGlobalFlyout, + removeContent: removeContentFromGlobalFlyout, + } = useGlobalFlyout(); + const { updateField, modal } = useUpdateField(); + + const { status, fieldToEdit } = documentFields; + const isEditing = status === 'editingField'; + + const field = isEditing ? fields.byId[fieldToEdit!] : undefined; + + const formDefaultValue = useMemo(() => { + return { ...field?.source }; + }, [field?.source]); const { form } = useForm({ - defaultValue: { ...field.source }, + defaultValue: formDefaultValue, serializer: fieldSerializer, deserializer: fieldDeserializer, options: { stripEmptyFields: false }, @@ -40,5 +54,48 @@ export const EditFieldContainer = React.memo(({ field, allFields }: Props) => { dispatch({ type: 'documentField.changeStatus', value: 'idle' }); }, [dispatch]); - return ; + useEffect(() => { + if (isEditing) { + // Open the flyout with the content + addContentToGlobalFlyout({ + id: 'mappingsEditField', + Component: EditField, + props: { + form, + field: field!, + exitEdit, + allFields: fields.byId, + updateField, + }, + flyoutProps: { ...defaultFlyoutProps, onClose: exitEdit }, + cleanUpFunc: exitEdit, + }); + } + }, [ + isEditing, + field, + form, + addContentToGlobalFlyout, + fields.byId, + fieldToEdit, + exitEdit, + updateField, + ]); + + useEffect(() => { + if (!isEditing) { + removeContentFromGlobalFlyout('mappingsEditField'); + } + }, [isEditing, removeContentFromGlobalFlyout]); + + useEffect(() => { + return () => { + if (isEditing) { + // When the component unmounts, exit edit mode. + exitEdit(); + } + }; + }, [isEditing, exitEdit]); + + return modal.isOpen ? : null; }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/update_field_provider.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/update_field_provider.tsx deleted file mode 100644 index e31d12689e7e0a..00000000000000 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/update_field_provider.tsx +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; - -import { useMappingsState, useDispatch } from '../../../../mappings_state'; -import { shouldDeleteChildFieldsAfterTypeChange, getAllDescendantAliases } from '../../../../lib'; -import { NormalizedField, DataType } from '../../../../types'; -import { PARAMETERS_DEFINITION } from '../../../../constants'; -import { ModalConfirmationDeleteFields } from '../modal_confirmation_delete_fields'; - -export type UpdateFieldFunc = (field: NormalizedField) => void; - -interface Props { - children: (saveProperty: UpdateFieldFunc) => React.ReactNode; -} - -interface State { - isModalOpen: boolean; - field?: NormalizedField; - aliases?: string[]; -} - -export const UpdateFieldProvider = ({ children }: Props) => { - const [state, setState] = useState({ - isModalOpen: false, - }); - const dispatch = useDispatch(); - - const { fields } = useMappingsState(); - const { byId, aliases } = fields; - - const confirmButtonText = i18n.translate( - 'xpack.idxMgmt.mappingsEditor.updateField.confirmationModal.confirmDescription', - { - defaultMessage: 'Confirm type change', - } - ); - - let modalTitle: string | undefined; - - if (state.field) { - const { source } = state.field; - - modalTitle = i18n.translate( - 'xpack.idxMgmt.mappingsEditor.updateField.confirmationModal.title', - { - defaultMessage: "Confirm change '{fieldName}' type to '{fieldType}'.", - values: { - fieldName: source.name, - fieldType: source.type, - }, - } - ); - } - - const closeModal = () => { - setState({ isModalOpen: false }); - }; - - const updateField: UpdateFieldFunc = (field) => { - const previousField = byId[field.id]; - - const willDeleteChildFields = (oldType: DataType, newType: DataType): boolean => { - const { hasChildFields, hasMultiFields } = field; - - if (!hasChildFields && !hasMultiFields) { - // No child or multi-fields will be deleted, no confirmation needed. - return false; - } - - return shouldDeleteChildFieldsAfterTypeChange(oldType, newType); - }; - - if (field.source.type !== previousField.source.type) { - // Array of all the aliases pointing to the current field beeing updated - const aliasesOnField = aliases[field.id] || []; - - // Array of all the aliases pointing to the current field + all its possible children - const aliasesOnFieldAndDescendants = getAllDescendantAliases(field, fields); - - const isReferencedByAlias = aliasesOnField && Boolean(aliasesOnField.length); - const nextTypeCanHaveAlias = !PARAMETERS_DEFINITION.path.targetTypesNotAllowed.includes( - field.source.type - ); - - // We need to check if, by changing the type, we will also - // delete possible child properties ("fields" or "properties"). - // If we will, we need to warn the user about it. - let requiresConfirmation: boolean; - let aliasesToDelete: string[] = []; - - if (isReferencedByAlias && !nextTypeCanHaveAlias) { - aliasesToDelete = aliasesOnFieldAndDescendants; - requiresConfirmation = true; - } else { - requiresConfirmation = willDeleteChildFields(previousField.source.type, field.source.type); - if (requiresConfirmation) { - aliasesToDelete = aliasesOnFieldAndDescendants.filter( - // We will only delete aliases that points to possible children, *NOT* the field itself - (id) => aliasesOnField.includes(id) === false - ); - } - } - - if (requiresConfirmation) { - setState({ - isModalOpen: true, - field, - aliases: Boolean(aliasesToDelete.length) - ? aliasesToDelete.map((id) => byId[id].path.join(' > ')).sort() - : undefined, - }); - return; - } - } - - dispatch({ type: 'field.edit', value: field.source }); - }; - - const confirmTypeUpdate = () => { - dispatch({ type: 'field.edit', value: state.field!.source }); - closeModal(); - }; - - return ( - <> - {children(updateField)} - - {state.isModalOpen && ( - - )} - - ); -}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/use_update_field.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/use_update_field.ts new file mode 100644 index 00000000000000..ed659cd05b060f --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/use_update_field.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { useMappingsState, useDispatch } from '../../../../mappings_state_context'; +import { shouldDeleteChildFieldsAfterTypeChange, getAllDescendantAliases } from '../../../../lib'; +import { NormalizedField, DataType } from '../../../../types'; +import { PARAMETERS_DEFINITION } from '../../../../constants'; + +export type UpdateFieldFunc = (field: NormalizedField) => void; + +interface State { + isModalOpen: boolean; + field?: NormalizedField; + aliases?: string[]; +} + +export const useUpdateField = () => { + const [state, setState] = useState({ + isModalOpen: false, + }); + const dispatch = useDispatch(); + + const { fields } = useMappingsState(); + const { byId, aliases } = fields; + + const confirmButtonText = i18n.translate( + 'xpack.idxMgmt.mappingsEditor.updateField.confirmationModal.confirmDescription', + { + defaultMessage: 'Confirm type change', + } + ); + + let modalTitle = ''; + + if (state.field) { + const { source } = state.field; + + modalTitle = i18n.translate( + 'xpack.idxMgmt.mappingsEditor.updateField.confirmationModal.title', + { + defaultMessage: "Confirm change '{fieldName}' type to '{fieldType}'.", + values: { + fieldName: source.name, + fieldType: source.type, + }, + } + ); + } + + const closeModal = () => { + setState({ isModalOpen: false }); + }; + + const updateField: UpdateFieldFunc = useCallback( + (field) => { + const previousField = byId[field.id]; + + const willDeleteChildFields = (oldType: DataType, newType: DataType): boolean => { + const { hasChildFields, hasMultiFields } = field; + + if (!hasChildFields && !hasMultiFields) { + // No child or multi-fields will be deleted, no confirmation needed. + return false; + } + + return shouldDeleteChildFieldsAfterTypeChange(oldType, newType); + }; + + if (field.source.type !== previousField.source.type) { + // Array of all the aliases pointing to the current field beeing updated + const aliasesOnField = aliases[field.id] || []; + + // Array of all the aliases pointing to the current field + all its possible children + const aliasesOnFieldAndDescendants = getAllDescendantAliases(field, fields); + + const isReferencedByAlias = aliasesOnField && Boolean(aliasesOnField.length); + const nextTypeCanHaveAlias = !PARAMETERS_DEFINITION.path.targetTypesNotAllowed.includes( + field.source.type + ); + + // We need to check if, by changing the type, we will also + // delete possible child properties ("fields" or "properties"). + // If we will, we need to warn the user about it. + let requiresConfirmation: boolean; + let aliasesToDelete: string[] = []; + + if (isReferencedByAlias && !nextTypeCanHaveAlias) { + aliasesToDelete = aliasesOnFieldAndDescendants; + requiresConfirmation = true; + } else { + requiresConfirmation = willDeleteChildFields( + previousField.source.type, + field.source.type + ); + if (requiresConfirmation) { + aliasesToDelete = aliasesOnFieldAndDescendants.filter( + // We will only delete aliases that points to possible children, *NOT* the field itself + (id) => aliasesOnField.includes(id) === false + ); + } + } + + if (requiresConfirmation) { + setState({ + isModalOpen: true, + field, + aliases: Boolean(aliasesToDelete.length) + ? aliasesToDelete.map((id) => byId[id].path.join(' > ')).sort() + : undefined, + }); + return; + } + } + + dispatch({ type: 'field.edit', value: field.source }); + }, + [dispatch, aliases, fields, byId] + ); + + const confirmTypeUpdate = () => { + dispatch({ type: 'field.edit', value: state.field!.source }); + closeModal(); + }; + + return { + updateField, + modal: { + isOpen: state.isModalOpen, + props: { + childFields: state.field && state.field.childFields, + title: modalTitle, + aliases: state.aliases, + byId, + confirmButtonText, + onConfirm: confirmTypeUpdate, + onCancel: closeModal, + }, + }, + }; +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item_container.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item_container.tsx index 55093e606cfa1f..7d9ad3bc6aaec4 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item_container.tsx @@ -5,7 +5,7 @@ */ import React, { useMemo, useCallback, useRef } from 'react'; -import { useMappingsState, useDispatch } from '../../../mappings_state'; +import { useMappingsState, useDispatch } from '../../../mappings_state_context'; import { NormalizedField } from '../../../types'; import { FieldsListItem } from './fields_list_item'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_json_editor.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_json_editor.tsx index 5954f6f285f108..d750c0e604c5e6 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_json_editor.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_json_editor.tsx @@ -6,7 +6,7 @@ import React, { useRef, useCallback } from 'react'; -import { useDispatch } from '../../mappings_state'; +import { useDispatch } from '../../mappings_state_context'; import { JsonEditor } from '../../shared_imports'; export interface Props { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_tree_editor.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_tree_editor.tsx index 9d9df38ef4e251..7a0b72ae647d50 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_tree_editor.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_tree_editor.tsx @@ -8,7 +8,7 @@ import React, { useMemo, useCallback } from 'react'; import { EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useMappingsState, useDispatch } from '../../mappings_state'; +import { useMappingsState, useDispatch } from '../../mappings_state_context'; import { FieldsList, CreateField } from './fields'; export const DocumentFieldsTreeEditor = () => { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result.tsx index 9077781b7fb43e..f3602a800eeeb2 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result.tsx @@ -8,9 +8,8 @@ import VirtualList from 'react-tiny-virtual-list'; import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { SearchResult as SearchResultType } from '../../../types'; -import { useDispatch } from '../../../mappings_state'; -import { State } from '../../../reducer'; +import { SearchResult as SearchResultType, State } from '../../../types'; +import { useDispatch } from '../../../mappings_state_context'; import { SearchResultItem } from './search_result_item'; interface Props { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result_item.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result_item.tsx index ab8b90b6be3b5c..73d3e078f6ff3f 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result_item.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result_item.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { SearchResult } from '../../../types'; import { TYPE_DEFINITION } from '../../../constants'; -import { useDispatch } from '../../../mappings_state'; +import { useDispatch } from '../../../mappings_state_context'; import { getTypeLabelFromType } from '../../../lib'; import { DeleteFieldProvider } from '../fields/delete_field_provider'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/index.ts index 34c410f06e5206..dc7f20f4d026b8 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './load_from_json_button'; -export * from './load_mappings_provider'; +export { LoadMappingsFromJsonButton } from './load_from_json_button'; +export { LoadMappingsProvider } from './load_mappings_provider'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx index a95579a8a141e1..44a809a7a01bf9 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx @@ -9,12 +9,11 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiText, EuiLink, EuiSpacer } from '@elastic/eui'; import { useForm, Form, SerializerFunc, UseField, JsonEditorField } from '../../shared_imports'; -import { Types, useDispatch } from '../../mappings_state'; +import { MappingsTemplates } from '../../types'; +import { useDispatch } from '../../mappings_state_context'; import { templatesFormSchema } from './templates_form_schema'; import { documentationService } from '../../../../services/documentation'; -type MappingsTemplates = Types['MappingsTemplates']; - interface Props { value?: MappingsTemplates; } diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form_schema.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form_schema.ts index 667b5685723d2a..daca85f95b0b90 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form_schema.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form_schema.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { FormSchema, fieldValidators } from '../../shared_imports'; -import { MappingsTemplates } from '../../reducer'; +import { MappingsTemplates } from '../../types'; const { isJsonField } = fieldValidators; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/index.ts index 29cfaf99c6559b..00bb41663dd9cf 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/index.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './mappings_editor'; +export { MappingsEditor } from './mappings_editor'; // We export both the button & the load mappings provider // to give flexibility to the consumer -export * from './components/load_mappings'; +export { LoadMappingsFromJsonButton, LoadMappingsProvider } from './components/load_mappings'; -export { OnUpdateHandler, Types } from './mappings_state'; +export { MappingsEditorProvider } from './mappings_editor_context'; -export { IndexSettings } from './types'; +export { IndexSettings, OnUpdateHandler } from './types'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx index 9e3637f970293d..411193f10b24af 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { createContext, useContext } from 'react'; + import { IndexSettings } from './types'; const IndexSettingsContext = createContext(undefined); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx index e8fda907377088..292882f1c5b4b2 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx @@ -14,24 +14,40 @@ import { TemplatesForm, MultipleMappingsWarning, } from './components'; -import { IndexSettings } from './types'; +import { + OnUpdateHandler, + IndexSettings, + Field, + Mappings, + MappingsConfiguration, + MappingsTemplates, +} from './types'; import { extractMappingsDefinition } from './lib'; -import { State } from './reducer'; -import { MappingsState, Props as MappingsStateProps, Types } from './mappings_state'; +import { useMappingsState } from './mappings_state_context'; +import { useMappingsStateListener } from './use_state_listener'; import { IndexSettingsProvider } from './index_settings_context'; +type TabName = 'fields' | 'advanced' | 'templates'; + +interface MappingsEditorParsedMetadata { + parsedDefaultValue?: { + configuration: MappingsConfiguration; + fields: { [key: string]: Field }; + templates: MappingsTemplates; + }; + multipleMappingsDeclared: boolean; +} + interface Props { - onChange: MappingsStateProps['onChange']; + onChange: OnUpdateHandler; value?: { [key: string]: any }; indexSettings?: IndexSettings; } -type TabName = 'fields' | 'advanced' | 'templates'; - export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Props) => { - const [selectedTab, selectTab] = useState('fields'); - - const { parsedDefaultValue, multipleMappingsDeclared } = useMemo(() => { + const { parsedDefaultValue, multipleMappingsDeclared } = useMemo< + MappingsEditorParsedMetadata + >(() => { const mappingsDefinition = extractMappingsDefinition(value); if (mappingsDefinition === null) { @@ -69,18 +85,28 @@ export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Pr return { parsedDefaultValue: parsed, multipleMappingsDeclared: false }; }, [value]); + /** + * Hook that will listen to: + * 1. "value" prop changes in order to reset the mappings editor + * 2. "state" changes in order to communicate any updates to the consumer + */ + useMappingsStateListener({ onChange, value: parsedDefaultValue }); + + const state = useMappingsState(); + const [selectedTab, selectTab] = useState('fields'); + useEffect(() => { if (multipleMappingsDeclared) { // We set the data getter here as the user won't be able to make any changes onChange({ - getData: () => value! as Types['Mappings'], + getData: () => value! as Mappings, validate: () => Promise.resolve(true), isValid: true, }); } }, [multipleMappingsDeclared, onChange, value]); - const changeTab = async (tab: TabName, state: State) => { + const changeTab = async (tab: TabName) => { if (selectedTab === 'advanced') { // When we navigate away we need to submit the form to validate if there are any errors. const { isValid: isConfigurationFormValid } = await state.configuration.submitForm!(); @@ -102,59 +128,53 @@ export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Pr selectTab(tab); }; + const tabToContentMap = { + fields: , + templates: , + advanced: , + }; + return (
{multipleMappingsDeclared ? ( ) : ( - - {({ state }) => { - const tabToContentMap = { - fields: , - templates: , - advanced: , - }; - - return ( -
- - changeTab('fields', state)} - isSelected={selectedTab === 'fields'} - data-test-subj="formTab" - > - {i18n.translate('xpack.idxMgmt.mappingsEditor.fieldsTabLabel', { - defaultMessage: 'Mapped fields', - })} - - changeTab('templates', state)} - isSelected={selectedTab === 'templates'} - data-test-subj="formTab" - > - {i18n.translate('xpack.idxMgmt.mappingsEditor.templatesTabLabel', { - defaultMessage: 'Dynamic templates', - })} - - changeTab('advanced', state)} - isSelected={selectedTab === 'advanced'} - data-test-subj="formTab" - > - {i18n.translate('xpack.idxMgmt.mappingsEditor.advancedTabLabel', { - defaultMessage: 'Advanced options', - })} - - - - - - {tabToContentMap[selectedTab]} -
- ); - }} -
+
+ + changeTab('fields')} + isSelected={selectedTab === 'fields'} + data-test-subj="formTab" + > + {i18n.translate('xpack.idxMgmt.mappingsEditor.fieldsTabLabel', { + defaultMessage: 'Mapped fields', + })} + + changeTab('templates')} + isSelected={selectedTab === 'templates'} + data-test-subj="formTab" + > + {i18n.translate('xpack.idxMgmt.mappingsEditor.templatesTabLabel', { + defaultMessage: 'Dynamic templates', + })} + + changeTab('advanced')} + isSelected={selectedTab === 'advanced'} + data-test-subj="formTab" + > + {i18n.translate('xpack.idxMgmt.mappingsEditor.advancedTabLabel', { + defaultMessage: 'Advanced options', + })} + + + + + + {tabToContentMap[selectedTab]} +
)}
diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor_context.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor_context.tsx new file mode 100644 index 00000000000000..596b49cc89ee80 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor_context.tsx @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +import { StateProvider } from './mappings_state_context'; + +export const MappingsEditorProvider: React.FC = ({ children }) => { + return {children}; +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state_context.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state_context.tsx new file mode 100644 index 00000000000000..a402dec250056d --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state_context.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useReducer, createContext, useContext } from 'react'; + +import { reducer } from './reducer'; +import { State, Dispatch } from './types'; + +const StateContext = createContext(undefined); +const DispatchContext = createContext(undefined); + +export const StateProvider: React.FC = ({ children }) => { + const initialState: State = { + isValid: true, + configuration: { + defaultValue: {}, + data: { + raw: {}, + format: () => ({}), + }, + validate: () => Promise.resolve(true), + }, + templates: { + defaultValue: {}, + data: { + raw: {}, + format: () => ({}), + }, + validate: () => Promise.resolve(true), + }, + fields: { + byId: {}, + rootLevelFields: [], + aliases: {}, + maxNestedDepth: 0, + }, + documentFields: { + status: 'idle', + editor: 'default', + }, + fieldsJsonEditor: { + format: () => ({}), + isValid: true, + }, + search: { + term: '', + result: [], + }, + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + return ( + + {children} + + ); +}; + +export const useMappingsState = () => { + const ctx = useContext(StateContext); + if (ctx === undefined) { + throw new Error('useMappingsState must be used within a '); + } + return ctx; +}; + +export const useDispatch = () => { + const ctx = useContext(DispatchContext); + if (ctx === undefined) { + throw new Error('useDispatch must be used within a '); + } + return ctx; +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts index 27f8b12493008a..18a8270117ea42 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.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 { OnFormUpdateArg, FormHook } from './shared_imports'; -import { Field, NormalizedFields, NormalizedField, FieldsEditor, SearchResult } from './types'; +import { Field, NormalizedFields, NormalizedField, State, Action } from './types'; import { getFieldMeta, getUniqueId, @@ -17,99 +16,6 @@ import { } from './lib'; import { PARAMETERS_DEFINITION } from './constants'; -export interface MappingsConfiguration { - enabled?: boolean; - throwErrorsForUnmappedFields?: boolean; - date_detection: boolean; - numeric_detection: boolean; - dynamic_date_formats: string[]; - _source: { - enabled?: boolean; - includes?: string[]; - excludes?: string[]; - }; - _meta?: string; -} - -export interface MappingsTemplates { - dynamic_templates: DynamicTemplate[]; -} - -interface DynamicTemplate { - [key: string]: { - mapping: { - [key: string]: any; - }; - match_mapping_type?: string; - match?: string; - unmatch?: string; - match_pattern?: string; - path_match?: string; - path_unmatch?: string; - }; -} - -export interface MappingsFields { - [key: string]: any; -} - -type DocumentFieldsStatus = 'idle' | 'editingField' | 'creatingField'; - -interface DocumentFieldsState { - status: DocumentFieldsStatus; - editor: FieldsEditor; - fieldToEdit?: string; - fieldToAddFieldTo?: string; -} - -interface ConfigurationFormState extends OnFormUpdateArg { - defaultValue: MappingsConfiguration; - submitForm?: FormHook['submit']; -} - -interface TemplatesFormState extends OnFormUpdateArg { - defaultValue: MappingsTemplates; - submitForm?: FormHook['submit']; -} - -export interface State { - isValid: boolean | undefined; - configuration: ConfigurationFormState; - documentFields: DocumentFieldsState; - fields: NormalizedFields; - fieldForm?: OnFormUpdateArg; - fieldsJsonEditor: { - format(): MappingsFields; - isValid: boolean; - }; - search: { - term: string; - result: SearchResult[]; - }; - templates: TemplatesFormState; -} - -export type Action = - | { type: 'editor.replaceMappings'; value: { [key: string]: any } } - | { type: 'configuration.update'; value: Partial } - | { type: 'configuration.save'; value: MappingsConfiguration } - | { type: 'templates.update'; value: Partial } - | { type: 'templates.save'; value: MappingsTemplates } - | { type: 'fieldForm.update'; value: OnFormUpdateArg } - | { type: 'field.add'; value: Field } - | { type: 'field.remove'; value: string } - | { type: 'field.edit'; value: Field } - | { type: 'field.toggleExpand'; value: { fieldId: string; isExpanded?: boolean } } - | { type: 'documentField.createField'; value?: string } - | { type: 'documentField.editField'; value: string } - | { type: 'documentField.changeStatus'; value: DocumentFieldsStatus } - | { type: 'documentField.changeEditor'; value: FieldsEditor } - | { type: 'fieldsJsonEditor.update'; value: { json: { [key: string]: any }; isValid: boolean } } - | { type: 'search:update'; value: string } - | { type: 'validity:update'; value: boolean }; - -export type Dispatch = (action: Action) => void; - export const addFieldToState = (field: Field, state: State): State => { const updatedFields = { ...state.fields }; const id = getUniqueId(); @@ -277,7 +183,7 @@ export const reducer = (state: State, action: Action): State => { }, documentFields: { ...state.documentFields, - status: 'idle', + ...action.value.documentFields, fieldToAddFieldTo: undefined, fieldToEdit: undefined, }, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/shared_imports.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/shared_imports.ts index 2979015c07455d..097d0395279502 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/shared_imports.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/shared_imports.ts @@ -49,4 +49,5 @@ export { export { JsonEditor, OnJsonEditorUpdateHandler, + GlobalFlyout, } from '../../../../../../../src/plugins/es_ui_shared/public'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts similarity index 65% rename from x-pack/plugins/index_management/public/application/components/mappings_editor/types.ts rename to x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts index 5b18af68ed55b9..a9f6d2ea03bdf8 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/types.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts @@ -3,10 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { ReactNode, OptionHTMLAttributes } from 'react'; +import { ReactNode } from 'react'; -import { FieldConfig } from './shared_imports'; -import { PARAMETERS_DEFINITION } from './constants'; +import { GenericObject } from './mappings_editor'; + +import { FieldConfig } from '../shared_imports'; +import { PARAMETERS_DEFINITION } from '../constants'; export interface DataTypeDefinition { label: string; @@ -203,100 +205,7 @@ export interface NormalizedField extends FieldMeta { export type ChildFieldName = 'properties' | 'fields'; -export type FieldsEditor = 'default' | 'json'; - -export type SelectOption = { - value: unknown; - text: T | ReactNode; -} & OptionHTMLAttributes; - -export interface SuperSelectOption { - value: unknown; - inputDisplay?: ReactNode; - dropdownDisplay?: ReactNode; - disabled?: boolean; - 'data-test-subj'?: string; -} - export interface AliasOption { id: string; label: string; } - -export interface IndexSettingsInterface { - analysis?: { - analyzer: { - [key: string]: { - type: string; - tokenizer: string; - char_filter?: string[]; - filter?: string[]; - position_increment_gap?: number; - }; - }; - }; -} - -/** - * When we define the index settings we can skip - * the "index" property and directly add the "analysis". - * ES always returns the settings wrapped under "index". - */ -export type IndexSettings = IndexSettingsInterface | { index: IndexSettingsInterface }; - -export interface ComboBoxOption { - label: string; - value?: unknown; -} - -export interface SearchResult { - display: JSX.Element; - field: NormalizedField; -} - -export interface SearchMetadata { - /** - * Whether or not the search term match some part of the field path. - */ - matchPath: boolean; - /** - * If the search term matches the field type we will give it a higher score. - */ - matchType: boolean; - /** - * If the last word of the search terms matches the field name - */ - matchFieldName: boolean; - /** - * If the search term matches the beginning of the path we will give it a higher score - */ - matchStartOfPath: boolean; - /** - * If the last word of the search terms fully matches the field name - */ - fullyMatchFieldName: boolean; - /** - * If the search term exactly matches the field type - */ - fullyMatchType: boolean; - /** - * If the search term matches the full field path - */ - fullyMatchPath: boolean; - /** - * The score of the result that will allow us to sort the list - */ - score: number; - /** - * The JSX with tag wrapping the matched string - */ - display: JSX.Element; - /** - * The field path substring that matches the search - */ - stringMatch: string | null; -} - -export interface GenericObject { - [key: string]: any; -} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/index.ts new file mode 100644 index 00000000000000..cce2d550a68c17 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/index.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 * from './mappings_editor'; + +export * from './document_fields'; + +export * from './state'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/mappings_editor.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/mappings_editor.ts new file mode 100644 index 00000000000000..1ca944024ae2b7 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/mappings_editor.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ReactNode, OptionHTMLAttributes } from 'react'; + +import { NormalizedField } from './document_fields'; +import { Mappings } from './state'; + +export type OnUpdateHandler = (arg: OnUpdateHandlerArg) => void; + +export interface OnUpdateHandlerArg { + isValid?: boolean; + getData: () => Mappings | undefined; + validate: () => Promise; +} + +export type FieldsEditor = 'default' | 'json'; + +export interface IndexSettingsInterface { + analysis?: { + analyzer: { + [key: string]: { + type: string; + tokenizer: string; + char_filter?: string[]; + filter?: string[]; + position_increment_gap?: number; + }; + }; + }; +} + +/** + * When we define the index settings we can skip + * the "index" property and directly add the "analysis". + * ES always returns the settings wrapped under "index". + */ +export type IndexSettings = IndexSettingsInterface | { index: IndexSettingsInterface }; + +export type SelectOption = { + value: unknown; + text: T | ReactNode; +} & OptionHTMLAttributes; + +export interface ComboBoxOption { + label: string; + value?: unknown; +} + +export interface SuperSelectOption { + value: unknown; + inputDisplay?: ReactNode; + dropdownDisplay?: ReactNode; + disabled?: boolean; + 'data-test-subj'?: string; +} + +export interface SearchResult { + display: JSX.Element; + field: NormalizedField; +} + +export interface SearchMetadata { + /** + * Whether or not the search term match some part of the field path. + */ + matchPath: boolean; + /** + * If the search term matches the field type we will give it a higher score. + */ + matchType: boolean; + /** + * If the last word of the search terms matches the field name + */ + matchFieldName: boolean; + /** + * If the search term matches the beginning of the path we will give it a higher score + */ + matchStartOfPath: boolean; + /** + * If the last word of the search terms fully matches the field name + */ + fullyMatchFieldName: boolean; + /** + * If the search term exactly matches the field type + */ + fullyMatchType: boolean; + /** + * If the search term matches the full field path + */ + fullyMatchPath: boolean; + /** + * The score of the result that will allow us to sort the list + */ + score: number; + /** + * The JSX with tag wrapping the matched string + */ + display: JSX.Element; + /** + * The field path substring that matches the search + */ + stringMatch: string | null; +} + +export interface GenericObject { + [key: string]: any; +} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/state.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/state.ts new file mode 100644 index 00000000000000..34df70374aa88d --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/state.ts @@ -0,0 +1,107 @@ +/* + * 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 { FormHook, OnFormUpdateArg } from '../shared_imports'; +import { Field, NormalizedFields } from './document_fields'; +import { FieldsEditor, SearchResult } from './mappings_editor'; + +export type Mappings = MappingsTemplates & + MappingsConfiguration & { + properties?: MappingsFields; + }; + +export interface MappingsConfiguration { + enabled?: boolean; + throwErrorsForUnmappedFields?: boolean; + date_detection?: boolean; + numeric_detection?: boolean; + dynamic_date_formats?: string[]; + _source?: { + enabled?: boolean; + includes?: string[]; + excludes?: string[]; + }; + _meta?: string; +} + +export interface MappingsTemplates { + dynamic_templates?: DynamicTemplate[]; +} + +export interface DynamicTemplate { + [key: string]: { + mapping: { + [key: string]: any; + }; + match_mapping_type?: string; + match?: string; + unmatch?: string; + match_pattern?: string; + path_match?: string; + path_unmatch?: string; + }; +} + +export interface MappingsFields { + [key: string]: any; +} + +export type DocumentFieldsStatus = 'idle' | 'editingField' | 'creatingField'; + +export interface DocumentFieldsState { + status: DocumentFieldsStatus; + editor: FieldsEditor; + fieldToEdit?: string; + fieldToAddFieldTo?: string; +} + +export interface ConfigurationFormState extends OnFormUpdateArg { + defaultValue: MappingsConfiguration; + submitForm?: FormHook['submit']; +} + +interface TemplatesFormState extends OnFormUpdateArg { + defaultValue: MappingsTemplates; + submitForm?: FormHook['submit']; +} + +export interface State { + isValid: boolean | undefined; + configuration: ConfigurationFormState; + documentFields: DocumentFieldsState; + fields: NormalizedFields; + fieldForm?: OnFormUpdateArg; + fieldsJsonEditor: { + format(): MappingsFields; + isValid: boolean; + }; + search: { + term: string; + result: SearchResult[]; + }; + templates: TemplatesFormState; +} + +export type Action = + | { type: 'editor.replaceMappings'; value: { [key: string]: any } } + | { type: 'configuration.update'; value: Partial } + | { type: 'configuration.save'; value: MappingsConfiguration } + | { type: 'templates.update'; value: Partial } + | { type: 'templates.save'; value: MappingsTemplates } + | { type: 'fieldForm.update'; value: OnFormUpdateArg } + | { type: 'field.add'; value: Field } + | { type: 'field.remove'; value: string } + | { type: 'field.edit'; value: Field } + | { type: 'field.toggleExpand'; value: { fieldId: string; isExpanded?: boolean } } + | { type: 'documentField.createField'; value?: string } + | { type: 'documentField.editField'; value: string } + | { type: 'documentField.changeStatus'; value: DocumentFieldsStatus } + | { type: 'documentField.changeEditor'; value: FieldsEditor } + | { type: 'fieldsJsonEditor.update'; value: { json: { [key: string]: any }; isValid: boolean } } + | { type: 'search:update'; value: string } + | { type: 'validity:update'; value: boolean }; + +export type Dispatch = (action: Action) => void; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx similarity index 53% rename from x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx rename to x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx index ad5056fa73ce16..f1ffd5356c977d 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx @@ -3,92 +3,32 @@ * 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, { useReducer, useEffect, createContext, useContext, useMemo, useRef } from 'react'; +import { useEffect, useMemo } from 'react'; import { - reducer, + Field, + Mappings, MappingsConfiguration, - MappingsFields, MappingsTemplates, - State, - Dispatch, -} from './reducer'; -import { Field } from './types'; + OnUpdateHandler, +} from './types'; import { normalize, deNormalize, stripUndefinedValues } from './lib'; +import { useMappingsState, useDispatch } from './mappings_state_context'; -type Mappings = MappingsTemplates & - MappingsConfiguration & { - properties?: MappingsFields; - }; - -export interface Types { - Mappings: Mappings; - MappingsConfiguration: MappingsConfiguration; - MappingsFields: MappingsFields; - MappingsTemplates: MappingsTemplates; -} - -export interface OnUpdateHandlerArg { - isValid?: boolean; - getData: () => Mappings | undefined; - validate: () => Promise; -} - -export type OnUpdateHandler = (arg: OnUpdateHandlerArg) => void; - -const StateContext = createContext(undefined); -const DispatchContext = createContext(undefined); - -export interface Props { - children: (params: { state: State }) => React.ReactNode; - value: { +interface Args { + onChange: OnUpdateHandler; + value?: { templates: MappingsTemplates; configuration: MappingsConfiguration; fields: { [key: string]: Field }; }; - onChange: OnUpdateHandler; } -export const MappingsState = React.memo(({ children, onChange, value }: Props) => { - const didMountRef = useRef(false); +export const useMappingsStateListener = ({ onChange, value }: Args) => { + const state = useMappingsState(); + const dispatch = useDispatch(); - const parsedFieldsDefaultValue = useMemo(() => normalize(value.fields), [value.fields]); - - const initialState: State = { - isValid: true, - configuration: { - defaultValue: value.configuration, - data: { - raw: value.configuration, - format: () => value.configuration, - }, - validate: () => Promise.resolve(true), - }, - templates: { - defaultValue: value.templates, - data: { - raw: value.templates, - format: () => value.templates, - }, - validate: () => Promise.resolve(true), - }, - fields: parsedFieldsDefaultValue, - documentFields: { - status: parsedFieldsDefaultValue.rootLevelFields.length === 0 ? 'creatingField' : 'idle', - editor: 'default', - }, - fieldsJsonEditor: { - format: () => ({}), - isValid: true, - }, - search: { - term: '', - result: [], - }, - }; - - const [state, dispatch] = useReducer(reducer, initialState); + const parsedFieldsDefaultValue = useMemo(() => normalize(value?.fields), [value?.fields]); useEffect(() => { // If we are creating a new field, but haven't entered any name @@ -158,46 +98,28 @@ export const MappingsState = React.memo(({ children, onChange, value }: Props) = }, isValid: state.isValid, }); - }, [state, onChange]); + }, [state, onChange, dispatch]); useEffect(() => { /** * If the value has changed that probably means that we have loaded * new data from JSON. We need to update our state with the new mappings. */ - if (didMountRef.current) { - dispatch({ - type: 'editor.replaceMappings', - value: { - configuration: value.configuration, - templates: value.templates, - fields: parsedFieldsDefaultValue, - }, - }); - } else { - didMountRef.current = true; + if (value === undefined) { + return; } - }, [value, parsedFieldsDefaultValue]); - - return ( - - {children({ state })} - - ); -}); - -export const useMappingsState = () => { - const ctx = useContext(StateContext); - if (ctx === undefined) { - throw new Error('useMappingsState must be used within a '); - } - return ctx; -}; -export const useDispatch = () => { - const ctx = useContext(DispatchContext); - if (ctx === undefined) { - throw new Error('useDispatch must be used within a '); - } - return ctx; + dispatch({ + type: 'editor.replaceMappings', + value: { + configuration: value.configuration, + templates: value.templates, + fields: parsedFieldsDefaultValue, + documentFields: { + status: parsedFieldsDefaultValue.rootLevelFields.length === 0 ? 'creatingField' : 'idle', + editor: 'default', + }, + }, + }); + }, [value, parsedFieldsDefaultValue, dispatch]); }; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components.tsx index df0cc791384fe5..ae831f4acf7eef 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components.tsx @@ -39,7 +39,7 @@ const i18nTexts = { ), }; -export const StepComponents = ({ defaultValue = [], onChange, esDocsBase }: Props) => { +export const StepComponents = ({ defaultValue, onChange, esDocsBase }: Props) => { const [state, setState] = useState<{ isLoadingComponents: boolean; components: ComponentTemplateListItem[]; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx index f3d05ac38108a2..fcc9795617ebb6 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx @@ -3,7 +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 React, { useEffect } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -153,25 +153,18 @@ export const StepLogistics: React.FunctionComponent = React.memo( serializer: formSerializer, deserializer: formDeserializer, }); + const { subscribe, submit, isSubmitted, isValid: isFormValid, getErrors: getFormErrors } = form; /** * When the consumer call validate() on this step, we submit the form so it enters the "isSubmitted" state * and we can display the form errors on top of the forms if there are any. */ - const validate = async () => { - return (await form.submit()).isValid; - }; + const validate = useCallback(async () => { + return (await submit()).isValid; + }, [submit]); useEffect(() => { - onChange({ - isValid: form.isValid, - validate, - getData: form.getFormData, - }); - }, [form.isValid, onChange]); // eslint-disable-line react-hooks/exhaustive-deps - - useEffect(() => { - const subscription = form.subscribe(({ data, isValid }) => { + const subscription = subscribe(({ data, isValid }) => { onChange({ isValid, validate, @@ -179,7 +172,7 @@ export const StepLogistics: React.FunctionComponent = React.memo( }); }); return subscription.unsubscribe; - }, [onChange]); // eslint-disable-line react-hooks/exhaustive-deps + }, [onChange, validate, subscribe]); const { name, indexPatterns, dataStream, order, priority, version } = getFieldsMeta( documentationService.getEsDocsBase() @@ -204,7 +197,7 @@ export const StepLogistics: React.FunctionComponent = React.memo( @@ -220,8 +213,8 @@ export const StepLogistics: React.FunctionComponent = React.memo(
{/* Name */} diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx index 0f4b9de4f6cfa3..1b4f19dda99f7e 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx @@ -24,6 +24,7 @@ import { serializers } from '../../../../shared_imports'; import { serializeLegacyTemplate, serializeTemplate } from '../../../../../common/lib'; import { TemplateDeserialized, getTemplateParameter } from '../../../../../common'; +import { SimulateTemplate } from '../../index_templates'; import { WizardSection } from '../template_form'; const { stripEmptyFields } = serializers; @@ -56,6 +57,27 @@ interface Props { navigateToStep: (stepId: WizardSection) => void; } +const PreviewTab = ({ template }: { template: { [key: string]: any } }) => { + return ( +
+ + + +

+ +

+
+ + + + +
+ ); +}; + export const StepReview: React.FunctionComponent = React.memo( ({ template, navigateToStep }) => { const { @@ -286,6 +308,33 @@ export const StepReview: React.FunctionComponent = React.memo( ); }; + const tabs = [ + { + id: 'summary', + name: i18n.translate('xpack.idxMgmt.templateForm.stepReview.summaryTabTitle', { + defaultMessage: 'Summary', + }), + content: , + }, + { + id: 'request', + name: i18n.translate('xpack.idxMgmt.templateForm.stepReview.requestTabTitle', { + defaultMessage: 'Request', + }), + content: , + }, + ]; + + if (!isLegacy) { + tabs.splice(1, 0, { + id: 'preview', + name: i18n.translate('xpack.idxMgmt.templateForm.stepReview.previewTabTitle', { + defaultMessage: 'Preview', + }), + content: , + }); + } + return (
@@ -331,25 +380,7 @@ export const StepReview: React.FunctionComponent = React.memo( ) : null} - , - }, - { - id: 'request', - name: i18n.translate('xpack.idxMgmt.templateForm.stepReview.requestTabTitle', { - defaultMessage: 'Request', - }), - content: , - }, - ]} - /> +
); } diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx index f5c9be9292cd05..fb0ba0b68fa6cb 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx @@ -3,14 +3,19 @@ * 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, { useCallback } from 'react'; +import React, { useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiSpacer } from '@elastic/eui'; +import { EuiSpacer, EuiButton } from '@elastic/eui'; import { TemplateDeserialized } from '../../../../common'; -import { serializers, Forms } from '../../../shared_imports'; +import { serializers, Forms, GlobalFlyout } from '../../../shared_imports'; import { SectionError } from '../section_error'; +import { + SimulateTemplateFlyoutContent, + SimulateTemplateProps, + simulateTemplateFlyoutProps, +} from '../index_templates'; import { StepLogisticsContainer, StepComponentContainer, StepReviewContainer } from './steps'; import { CommonWizardSteps, @@ -22,8 +27,10 @@ import { documentationService } from '../../services/documentation'; const { stripEmptyFields } = serializers; const { FormWizard, FormWizardStep } = Forms; +const { useGlobalFlyout } = GlobalFlyout; interface Props { + title: string | JSX.Element; onSave: (template: TemplateDeserialized) => void; clearSaveError: () => void; isSaving: boolean; @@ -80,6 +87,7 @@ const wizardSections: { [id: string]: { id: WizardSection; label: string } } = { }; export const TemplateForm = ({ + title, defaultValue, isEditing, isSaving, @@ -88,6 +96,9 @@ export const TemplateForm = ({ clearSaveError, onSave, }: Props) => { + const [wizardContent, setWizardContent] = useState | null>(null); + const { addContent: addContentToGlobalFlyout, closeFlyout } = useGlobalFlyout(); + const indexTemplate = defaultValue ?? { name: '', indexPatterns: [], @@ -189,6 +200,10 @@ export const TemplateForm = ({ [] ); + const onWizardContentChange = useCallback((content: Forms.Content) => { + setWizardContent(content); + }, []); + const onSaveTemplate = useCallback( async (wizardData: WizardContent) => { const template = buildTemplateObject(indexTemplate)(wizardData); @@ -206,44 +221,101 @@ export const TemplateForm = ({ [indexTemplate, buildTemplateObject, onSave, clearSaveError] ); + const getSimulateTemplate = useCallback(async () => { + if (!wizardContent) { + return; + } + const isValid = await wizardContent.validate(); + if (!isValid) { + return; + } + const wizardData = wizardContent.getData(); + const template = buildTemplateObject(indexTemplate)(wizardData); + return template; + }, [buildTemplateObject, indexTemplate, wizardContent]); + + const showPreviewFlyout = () => { + addContentToGlobalFlyout({ + id: 'simulateTemplate', + Component: SimulateTemplateFlyoutContent, + props: { + getTemplate: getSimulateTemplate, + onClose: closeFlyout, + }, + flyoutProps: simulateTemplateFlyoutProps, + }); + }; + + const getRightContentWizardNav = (stepId: WizardSection) => { + if (isLegacy) { + return null; + } + + // Don't show "Preview template" button on logistics and review steps + if (stepId === 'logistics' || stepId === 'review') { + return null; + } + + return ( + + + + ); + }; + return ( - - defaultValue={wizardDefaultValue} - onSave={onSaveTemplate} - isEditing={isEditing} - isSaving={isSaving} - apiError={apiError} - texts={i18nTexts} - > - - - + <> + {/* Form header */} + {title} - {indexTemplate._kbnMeta.isLegacy !== true && ( - - + + + + defaultValue={wizardDefaultValue} + onSave={onSaveTemplate} + isEditing={isEditing} + isSaving={isSaving} + apiError={apiError} + texts={i18nTexts} + onChange={onWizardContentChange} + rightContentNav={getRightContentWizardNav} + > + + - )} - - - + {indexTemplate._kbnMeta.isLegacy !== true && ( + + + + )} - - - + + + + + + + - - - + + + - - - - + + + + + ); }; diff --git a/x-pack/plugins/index_management/public/application/index.tsx b/x-pack/plugins/index_management/public/application/index.tsx index ebc29ac86a17f0..f881c2e01cefc0 100644 --- a/x-pack/plugins/index_management/public/application/index.tsx +++ b/x-pack/plugins/index_management/public/application/index.tsx @@ -11,11 +11,14 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { CoreStart } from '../../../../../src/core/public'; import { API_BASE_PATH } from '../../common'; +import { GlobalFlyout } from '../shared_imports'; import { AppContextProvider, AppDependencies } from './app_context'; import { App } from './app'; import { indexManagementStore } from './store'; -import { ComponentTemplatesProvider } from './components'; +import { ComponentTemplatesProvider, MappingsEditorProvider } from './components'; + +const { GlobalFlyoutProvider } = GlobalFlyout; export const renderApp = ( elem: HTMLElement | null, @@ -43,9 +46,13 @@ export const renderApp = ( - - - + + + + + + + , diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/index.ts b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/index.ts index 08ebda2b5e437c..11a86e78be99c7 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/index.ts +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/index.ts @@ -5,3 +5,4 @@ */ export { TabSummary } from './tab_summary'; +export { TabPreview } from './tab_preview'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_preview.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_preview.tsx new file mode 100644 index 00000000000000..ec52bcbab3b0bc --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_preview.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiText, EuiSpacer } from '@elastic/eui'; +import { TemplateDeserialized } from '../../../../../../../common'; +import { SimulateTemplate } from '../../../../../components/index_templates'; + +interface Props { + templateDetails: TemplateDeserialized; +} + +export const TabPreview = ({ templateDetails }: Props) => { + return ( +
+ +

+ +

+
+ + + + +
+ ); +}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details.tsx index faeca2f2487a87..c03f64880a700d 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details.tsx @@ -15,8 +15,6 @@ export const TemplateDetails = (props: Props) => { onClose={props.onClose} data-test-subj="templateDetails" aria-labelledby="templateDetailsFlyoutTitle" - size="m" - maxWidth={500} > diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx index 5b726013a1d922..5bacffc4c24042 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx @@ -29,6 +29,7 @@ import { UIM_TEMPLATE_DETAIL_PANEL_SUMMARY_TAB, UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB, UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB, + UIM_TEMPLATE_DETAIL_PANEL_PREVIEW_TAB, } from '../../../../../../common/constants'; import { SendRequestResponse } from '../../../../../shared_imports'; import { TemplateDeleteModal, SectionLoading, SectionError, Error } from '../../../../components'; @@ -37,12 +38,13 @@ import { decodePathFromReactRouter } from '../../../../services/routing'; import { useServices } from '../../../../app_context'; import { TabAliases, TabMappings, TabSettings } from '../../../../components/shared'; import { TemplateTypeIndicator } from '../components'; -import { TabSummary } from './tabs'; +import { TabSummary, TabPreview } from './tabs'; const SUMMARY_TAB_ID = 'summary'; const MAPPINGS_TAB_ID = 'mappings'; const ALIASES_TAB_ID = 'aliases'; const SETTINGS_TAB_ID = 'settings'; +const PREVIEW_TAB_ID = 'preview'; const TABS = [ { @@ -69,6 +71,12 @@ const TABS = [ defaultMessage: 'Aliases', }), }, + { + id: PREVIEW_TAB_ID, + name: i18n.translate('xpack.idxMgmt.templateDetails.previewTabTitle', { + defaultMessage: 'Preview', + }), + }, ]; const tabToUiMetricMap: { [key: string]: string } = { @@ -76,6 +84,7 @@ const tabToUiMetricMap: { [key: string]: string } = { [SETTINGS_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB, [MAPPINGS_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_MAPPINGS_TAB, [ALIASES_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB, + [PREVIEW_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_PREVIEW_TAB, }; export interface Props { @@ -161,6 +170,7 @@ export const TemplateDetailsContent = ({ [SETTINGS_TAB_ID]: , [MAPPINGS_TAB_ID]: , [ALIASES_TAB_ID]: , + [PREVIEW_TAB_ID]: , }; const tabContent = tabToComponentMap[activeTab]; @@ -191,7 +201,13 @@ export const TemplateDetailsContent = ({ {managedTemplateCallout} - {TABS.map((tab) => ( + {TABS.filter((tab) => { + // Legacy index templates don't have the "simulate" template API + if (isLegacy && tab.id === PREVIEW_TAB_ID) { + return false; + } + return true; + }).map((tab) => ( { uiMetricService.trackMetric('click', tabToUiMetricMap[tab.id]); diff --git a/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx b/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx index 82835c56a38775..2aaecbd64ee28d 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiPageBody, EuiPageContent, EuiTitle } from '@elastic/eui'; import { TemplateDeserialized } from '../../../../common'; import { TemplateForm, SectionLoading, SectionError, Error } from '../../components'; @@ -94,30 +94,30 @@ export const TemplateClone: React.FunctionComponent +

+ +

+ + } defaultValue={templateData} onSave={onSave} isSaving={isSaving} saveError={saveError} clearSaveError={clearSaveError} + isLegacy={isLegacy} /> ); } return ( - - -

- -

-
- - {content} -
+ {content}
); }; diff --git a/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx b/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx index fb82f52968eb4b..691d2598d56d99 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiPageBody, EuiPageContent, EuiTitle } from '@elastic/eui'; import { useLocation } from 'react-router-dom'; import { parse } from 'query-string'; @@ -51,23 +51,24 @@ export const TemplateCreate: React.FunctionComponent = ({ h return ( - -

- {isLegacy ? ( - - ) : ( - - )} -

-
- +

+ {isLegacy ? ( + + ) : ( + + )} +

+ + } onSave={onSave} isSaving={isSaving} saveError={saveError} diff --git a/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx b/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx index 29fd2e02120fc1..6bdcd03fa5ca40 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx @@ -133,12 +133,24 @@ export const TemplateEdit: React.FunctionComponent )} +

+ +

+ + } defaultValue={template} onSave={onSave} isSaving={isSaving} saveError={saveError} clearSaveError={clearSaveError} isEditing={true} + isLegacy={isLegacy} /> ); @@ -147,19 +159,7 @@ export const TemplateEdit: React.FunctionComponent - - -

- -

-
- - {content} -
+ {content}
); }; diff --git a/x-pack/plugins/index_management/public/application/services/api.ts b/x-pack/plugins/index_management/public/application/services/api.ts index d7874ec2dcf325..546a0115ee4a9a 100644 --- a/x-pack/plugins/index_management/public/application/services/api.ts +++ b/x-pack/plugins/index_management/public/application/services/api.ts @@ -30,6 +30,7 @@ import { UIM_TEMPLATE_CREATE, UIM_TEMPLATE_UPDATE, UIM_TEMPLATE_CLONE, + UIM_TEMPLATE_SIMULATE, } from '../../../common/constants'; import { TemplateDeserialized, TemplateListItem, DataStream } from '../../../common'; import { IndexMgmtMetricsType } from '../../types'; @@ -286,3 +287,14 @@ export async function updateTemplate(template: TemplateDeserialized) { return result; } + +export function simulateIndexTemplate(template: { [key: string]: any }) { + return sendRequest({ + path: `${API_BASE_PATH}/index_templates/simulate`, + method: 'post', + body: JSON.stringify(template), + }).then((result) => { + uiMetricService.trackMetric('count', UIM_TEMPLATE_SIMULATE); + return result; + }); +} diff --git a/x-pack/plugins/index_management/public/application/services/documentation.ts b/x-pack/plugins/index_management/public/application/services/documentation.ts index 972b4f4b25680d..afc9c76f1afbec 100644 --- a/x-pack/plugins/index_management/public/application/services/documentation.ts +++ b/x-pack/plugins/index_management/public/application/services/documentation.ts @@ -40,8 +40,10 @@ class DocumentationService { return `${this.esDocsBase}/data-streams.html`; } - public getTemplatesDocumentationLink() { - return `${this.esDocsBase}/indices-templates.html`; + public getTemplatesDocumentationLink(isLegacy = false) { + return isLegacy + ? `${this.esDocsBase}/indices-templates-v1.html` + : `${this.esDocsBase}/indices-templates.html`; } public getIdxMgmtDocumentationLink() { diff --git a/x-pack/plugins/index_management/public/application/services/index.ts b/x-pack/plugins/index_management/public/application/services/index.ts index 2334d32adf1314..a78e0bac14ae1d 100644 --- a/x-pack/plugins/index_management/public/application/services/index.ts +++ b/x-pack/plugins/index_management/public/application/services/index.ts @@ -22,6 +22,7 @@ export { loadIndexMapping, loadIndexData, useLoadIndexTemplates, + simulateIndexTemplate, } from './api'; export { healthToColor } from './health_to_color'; export { sortTable } from './sort_table'; diff --git a/x-pack/plugins/index_management/public/shared_imports.ts b/x-pack/plugins/index_management/public/shared_imports.ts index 3f7fcf424f1f06..16dcab18c3caf4 100644 --- a/x-pack/plugins/index_management/public/shared_imports.ts +++ b/x-pack/plugins/index_management/public/shared_imports.ts @@ -12,6 +12,7 @@ export { useRequest, Forms, extractQueryParams, + GlobalFlyout, } from '../../../../src/plugins/es_ui_shared/public/'; export { diff --git a/x-pack/plugins/index_management/server/client/elasticsearch.ts b/x-pack/plugins/index_management/server/client/elasticsearch.ts index 9f8bce241ae69b..ed5ede07479ca5 100644 --- a/x-pack/plugins/index_management/server/client/elasticsearch.ts +++ b/x-pack/plugins/index_management/server/client/elasticsearch.ts @@ -182,4 +182,14 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) ], method: 'HEAD', }); + + dataManagement.simulateTemplate = ca({ + urls: [ + { + fmt: '/_index_template/_simulate', + }, + ], + needBody: true, + method: 'POST', + }); }; diff --git a/x-pack/plugins/index_management/server/routes/api/templates/register_simulate_route.ts b/x-pack/plugins/index_management/server/routes/api/templates/register_simulate_route.ts new file mode 100644 index 00000000000000..9d078e135fd52e --- /dev/null +++ b/x-pack/plugins/index_management/server/routes/api/templates/register_simulate_route.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../index'; + +const bodySchema = schema.object({}, { unknowns: 'allow' }); + +export function registerSimulateRoute({ router, license, lib }: RouteDependencies) { + router.post( + { + path: addBasePath('/index_templates/simulate'), + validate: { body: bodySchema }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { callAsCurrentUser } = ctx.dataManagement!.client; + const template = req.body as TypeOf; + + try { + const templatePreview = await callAsCurrentUser('dataManagement.simulateTemplate', { + body: template, + }); + + return res.ok({ body: templatePreview }); + } catch (e) { + if (lib.isEsError(e)) { + return res.customError({ + statusCode: e.statusCode, + body: e, + }); + } + // Case: default + return res.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/index_management/server/routes/api/templates/register_template_routes.ts b/x-pack/plugins/index_management/server/routes/api/templates/register_template_routes.ts index 2b657346a2f829..e25f2abdfee780 100644 --- a/x-pack/plugins/index_management/server/routes/api/templates/register_template_routes.ts +++ b/x-pack/plugins/index_management/server/routes/api/templates/register_template_routes.ts @@ -10,6 +10,7 @@ import { registerGetAllRoute, registerGetOneRoute } from './register_get_routes' import { registerDeleteRoute } from './register_delete_route'; import { registerCreateRoute } from './register_create_route'; import { registerUpdateRoute } from './register_update_route'; +import { registerSimulateRoute } from './register_simulate_route'; export function registerTemplateRoutes(dependencies: RouteDependencies) { registerGetAllRoute(dependencies); @@ -17,4 +18,5 @@ export function registerTemplateRoutes(dependencies: RouteDependencies) { registerDeleteRoute(dependencies); registerCreateRoute(dependencies); registerUpdateRoute(dependencies); + registerSimulateRoute(dependencies); } diff --git a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json index 4b10dab5d1ae55..e16edac5ddb7a4 100644 --- a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json +++ b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json @@ -4203,6 +4203,7 @@ "FAILED", "STOPPING", "STOPPED", + "DEGRADED", "DATA_DUMP", "ACKNOWLEDGED", "UNKNOWN" diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent.ts b/x-pack/plugins/ingest_manager/common/types/models/agent.ts index d3789c58a2c228..f31d33e73c76f6 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent.ts @@ -53,6 +53,7 @@ export interface NewAgentEvent { | 'FAILED' | 'STOPPING' | 'STOPPED' + | 'DEGRADED' // Action results | 'DATA_DUMP' // Actions diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_define_package_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_define_package_config.tsx index a04d023ebcc487..f487b4e5235e71 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_define_package_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_define_package_config.tsx @@ -47,9 +47,17 @@ export const StepDefinePackageConfig: React.FunctionComponent<{ .sort(); updatePackageConfig({ - name: `${packageInfo.name}-${ - dsWithMatchingNames.length ? dsWithMatchingNames[dsWithMatchingNames.length - 1] + 1 : 1 - }`, + name: + // For Endpoint packages, the user must fill in the name, thus we don't attempt to generate + // a default one here. + // FIXME: Improve package configs name uniqueness - https://github.com/elastic/kibana/issues/72948 + packageInfo.name !== 'endpoint' + ? `${packageInfo.name}-${ + dsWithMatchingNames.length + ? dsWithMatchingNames[dsWithMatchingNames.length - 1] + 1 + : 1 + }` + : '', package: { name: packageInfo.name, title: packageInfo.title, diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx index e9cb59be37892f..43e4d696ded660 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx @@ -95,6 +95,14 @@ export const SUBTYPE_LABEL: { [key in AgentEvent['subtype']]: JSX.Element } = { /> ), + DEGRADED: ( + + + + ), DATA_DUMP: ( { + const streamTemplate = ` +input: log +paths: +{{#each paths}} + - {{this}} +{{/each}} +exclude_files: [".gz$"] +tags: +{{#each tags}} + - {{this}} +{{/each}} +{{#contains "forwarded" tags}} +publisher_pipeline.disable_host: true +{{/contains}} +processors: + - add_locale: ~ +password: {{password}} +{{#if password}} +hidden_password: {{password}} +{{/if}} + `; + + it('should support when a value is not contained in the array', () => { + const vars = { + paths: { value: ['/usr/local/var/log/nginx/access.log'] }, + password: { type: 'password', value: '' }, + tags: { value: ['foo', 'bar', 'forwarded'] }, + }; + + const output = createStream(vars, streamTemplate); + expect(output).toEqual({ + input: 'log', + paths: ['/usr/local/var/log/nginx/access.log'], + exclude_files: ['.gz$'], + processors: [{ add_locale: null }], + password: '', + 'publisher_pipeline.disable_host': true, + tags: ['foo', 'bar', 'forwarded'], + }); + }); + + it('should support when a value is contained in the array', () => { + const vars = { + paths: { value: ['/usr/local/var/log/nginx/access.log'] }, + password: { type: 'password', value: '' }, + tags: { value: ['foo', 'bar'] }, + }; + + const output = createStream(vars, streamTemplate); + expect(output).toEqual({ + input: 'log', + paths: ['/usr/local/var/log/nginx/access.log'], + exclude_files: ['.gz$'], + processors: [{ add_locale: null }], + password: '', + tags: ['foo', 'bar'], + }); + }); + }); + it('should support optional yaml values at root level', () => { const streamTemplate = ` input: logs diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts index d697ad0576396e..88c54d213554cb 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts @@ -8,10 +8,12 @@ import Handlebars from 'handlebars'; import { safeLoad, safeDump } from 'js-yaml'; import { PackageConfigConfigRecord } from '../../../../common'; +const handlebars = Handlebars.create(); + export function createStream(variables: PackageConfigConfigRecord, streamTemplate: string) { const { vars, yamlValues } = buildTemplateVariables(variables, streamTemplate); - const template = Handlebars.compile(streamTemplate, { noEscape: true }); + const template = handlebars.compile(streamTemplate, { noEscape: true }); let stream = template(vars); stream = replaceRootLevelYamlVariables(yamlValues, stream); @@ -87,6 +89,16 @@ function buildTemplateVariables(variables: PackageConfigConfigRecord, streamTemp return { vars, yamlValues }; } +function containsHelper(this: any, item: string, list: string[], options: any) { + if (Array.isArray(list) && list.includes(item)) { + if (options && options.fn) { + return options.fn(this); + } + } + return ''; +} +handlebars.registerHelper('contains', containsHelper); + function replaceRootLevelYamlVariables(yamlVariables: { [k: string]: any }, yamlTemplate: string) { if (Object.keys(yamlVariables).length === 0 || !yamlTemplate) { return yamlTemplate; diff --git a/x-pack/plugins/ingest_manager/server/types/models/agent.ts b/x-pack/plugins/ingest_manager/server/types/models/agent.ts index 1b396db9b0c88f..ea5eacc8e63d0b 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/agent.ts @@ -22,13 +22,16 @@ const AgentEventBase = { ]), subtype: schema.oneOf([ // State - schema.literal('RUNNING'), - schema.literal('STARTING'), - schema.literal('IN_PROGRESS'), - schema.literal('CONFIG'), - schema.literal('FAILED'), - schema.literal('STOPPING'), - schema.literal('STOPPED'), + schema.oneOf([ + schema.literal('RUNNING'), + schema.literal('STARTING'), + schema.literal('IN_PROGRESS'), + schema.literal('CONFIG'), + schema.literal('FAILED'), + schema.literal('STOPPING'), + schema.literal('STOPPED'), + schema.literal('DEGRADED'), + ]), // Action results schema.literal('DATA_DUMP'), // Actions diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_list.ts b/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_list.ts index b596b831f2d68a..2e9bb1325632e8 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_list.ts @@ -60,7 +60,7 @@ export const createEndpointList = async ({ ); return transformSavedObjectToExceptionList({ savedObject }); } catch (err) { - if (err.status === 409) { + if (savedObjectsClient.errors.isConflictError(err)) { return null; } else { throw err; diff --git a/x-pack/plugins/maps/common/descriptor_types/sources.ts b/x-pack/plugins/maps/common/descriptor_types/sources.ts index 7eda37bf53351d..6e8884d942e19f 100644 --- a/x-pack/plugins/maps/common/descriptor_types/sources.ts +++ b/x-pack/plugins/maps/common/descriptor_types/sources.ts @@ -168,6 +168,7 @@ export type LayerDescriptor = { __trackedLayerDescriptor?: LayerDescriptor; alpha?: number; id: string; + joins?: JoinDescriptor[]; label?: string | null; areLabelsOnTop?: boolean; minZoom?: number; @@ -180,7 +181,6 @@ export type LayerDescriptor = { }; export type VectorLayerDescriptor = LayerDescriptor & { - joins?: JoinDescriptor[]; style?: VectorStyleDescriptor; }; diff --git a/x-pack/plugins/maps/public/classes/layers/layer.test.ts b/x-pack/plugins/maps/public/classes/layers/layer.test.ts new file mode 100644 index 00000000000000..f25ecd7106457f --- /dev/null +++ b/x-pack/plugins/maps/public/classes/layers/layer.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable max-classes-per-file */ + +import { AbstractLayer } from './layer'; +import { ISource } from '../sources/source'; +import { IStyle } from '../styles/style'; +import { AGG_TYPE, FIELD_ORIGIN, LAYER_STYLE_TYPE, VECTOR_STYLES } from '../../../common/constants'; +import { ESTermSourceDescriptor, VectorStyleDescriptor } from '../../../common/descriptor_types'; +import { getDefaultDynamicProperties } from '../styles/vector/vector_style_defaults'; + +jest.mock('uuid/v4', () => { + return function () { + return '12345'; + }; +}); + +class MockLayer extends AbstractLayer {} + +class MockSource { + cloneDescriptor() { + return {}; + } + + getDisplayName() { + return 'mySource'; + } +} + +class MockStyle {} + +describe('cloneDescriptor', () => { + describe('with joins', () => { + const styleDescriptor = { + type: LAYER_STYLE_TYPE.VECTOR, + properties: { + ...getDefaultDynamicProperties(), + }, + } as VectorStyleDescriptor; + // @ts-expect-error + styleDescriptor.properties[VECTOR_STYLES.FILL_COLOR].options.field = { + name: '__kbnjoin__count__557d0f15', + origin: FIELD_ORIGIN.JOIN, + }; + // @ts-expect-error + styleDescriptor.properties[VECTOR_STYLES.LINE_COLOR].options.field = { + name: 'bytes', + origin: FIELD_ORIGIN.SOURCE, + }; + // @ts-expect-error + styleDescriptor.properties[VECTOR_STYLES.LABEL_BORDER_COLOR].options.field = { + name: '__kbnjoin__count__6666666666', + origin: FIELD_ORIGIN.JOIN, + }; + + test('Should update data driven styling properties using join fields', async () => { + const layerDescriptor = AbstractLayer.createDescriptor({ + style: styleDescriptor, + joins: [ + { + leftField: 'iso2', + right: { + id: '557d0f15', + indexPatternId: 'myIndexPattern', + indexPatternTitle: 'logs-*', + metrics: [{ type: AGG_TYPE.COUNT }], + term: 'myTermField', + type: 'joinSource', + }, + }, + ], + }); + const layer = new MockLayer({ + layerDescriptor, + source: (new MockSource() as unknown) as ISource, + style: (new MockStyle() as unknown) as IStyle, + }); + const clonedDescriptor = await layer.cloneDescriptor(); + const clonedStyleProps = (clonedDescriptor.style as VectorStyleDescriptor).properties; + // Should update style field belonging to join + // @ts-expect-error + expect(clonedStyleProps[VECTOR_STYLES.FILL_COLOR].options.field.name).toEqual( + '__kbnjoin__count__12345' + ); + // Should not update style field belonging to source + // @ts-expect-error + expect(clonedStyleProps[VECTOR_STYLES.LINE_COLOR].options.field.name).toEqual('bytes'); + // Should not update style feild belonging to different join + // @ts-expect-error + expect(clonedStyleProps[VECTOR_STYLES.LABEL_BORDER_COLOR].options.field.name).toEqual( + '__kbnjoin__count__6666666666' + ); + }); + + test('Should update data driven styling properties using join fields when metrics are not provided', async () => { + const layerDescriptor = AbstractLayer.createDescriptor({ + style: styleDescriptor, + joins: [ + { + leftField: 'iso2', + right: ({ + id: '557d0f15', + indexPatternId: 'myIndexPattern', + indexPatternTitle: 'logs-*', + term: 'myTermField', + type: 'joinSource', + } as unknown) as ESTermSourceDescriptor, + }, + ], + }); + const layer = new MockLayer({ + layerDescriptor, + source: (new MockSource() as unknown) as ISource, + style: (new MockStyle() as unknown) as IStyle, + }); + const clonedDescriptor = await layer.cloneDescriptor(); + const clonedStyleProps = (clonedDescriptor.style as VectorStyleDescriptor).properties; + // Should update style field belonging to join + // @ts-expect-error + expect(clonedStyleProps[VECTOR_STYLES.FILL_COLOR].options.field.name).toEqual( + '__kbnjoin__count__12345' + ); + }); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index d8def155a9185b..424100c5a7e3a7 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -14,16 +14,26 @@ import { i18n } from '@kbn/i18n'; import { FeatureCollection } from 'geojson'; import { DataRequest } from '../util/data_request'; import { + AGG_TYPE, + FIELD_ORIGIN, MAX_ZOOM, MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER, MIN_ZOOM, SOURCE_DATA_REQUEST_ID, + STYLE_TYPE, } from '../../../common/constants'; import { copyPersistentState } from '../../reducers/util'; -import { LayerDescriptor, MapExtent, StyleDescriptor } from '../../../common/descriptor_types'; +import { + AggDescriptor, + JoinDescriptor, + LayerDescriptor, + MapExtent, + StyleDescriptor, +} from '../../../common/descriptor_types'; import { Attribution, ImmutableSourceProperty, ISource, SourceEditorArgs } from '../sources/source'; import { DataRequestContext } from '../../actions'; import { IStyle } from '../styles/style'; +import { getJoinAggKey } from '../../../common/get_agg_key'; export interface ILayer { getBounds(dataRequestContext: DataRequestContext): Promise; @@ -157,10 +167,43 @@ export class AbstractLayer implements ILayer { clonedDescriptor.sourceDescriptor = this.getSource().cloneDescriptor(); if (clonedDescriptor.joins) { - // @ts-expect-error - clonedDescriptor.joins.forEach((joinDescriptor) => { + clonedDescriptor.joins.forEach((joinDescriptor: JoinDescriptor) => { + const originalJoinId = joinDescriptor.right.id!; + // right.id is uuid used to track requests in inspector joinDescriptor.right.id = uuid(); + + // Update all data driven styling properties using join fields + if (clonedDescriptor.style && 'properties' in clonedDescriptor.style) { + const metrics = + joinDescriptor.right.metrics && joinDescriptor.right.metrics.length + ? joinDescriptor.right.metrics + : [{ type: AGG_TYPE.COUNT }]; + metrics.forEach((metricsDescriptor: AggDescriptor) => { + const originalJoinKey = getJoinAggKey({ + aggType: metricsDescriptor.type, + aggFieldName: metricsDescriptor.field ? metricsDescriptor.field : '', + rightSourceId: originalJoinId, + }); + const newJoinKey = getJoinAggKey({ + aggType: metricsDescriptor.type, + aggFieldName: metricsDescriptor.field ? metricsDescriptor.field : '', + rightSourceId: joinDescriptor.right.id!, + }); + + Object.keys(clonedDescriptor.style.properties).forEach((key) => { + const styleProp = clonedDescriptor.style.properties[key]; + if ( + styleProp.type === STYLE_TYPE.DYNAMIC && + styleProp.options.field && + styleProp.options.field.origin === FIELD_ORIGIN.JOIN && + styleProp.options.field.name === originalJoinKey + ) { + styleProp.options.field.name = newJoinKey; + } + }); + }); + } }); } return clonedDescriptor; diff --git a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/__snapshots__/xyz_tms_editor.test.tsx.snap b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/__snapshots__/xyz_tms_editor.test.tsx.snap new file mode 100644 index 00000000000000..b8ed4a727fad0e --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/__snapshots__/xyz_tms_editor.test.tsx.snap @@ -0,0 +1,237 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`attribution validation should provide no validation errors when attribution text and attribution url are provided 1`] = ` + + + + + + + + + + + +`; + +exports[`attribution validation should provide validation error when attribution text is provided without attribution url 1`] = ` + + + + + + + + + + + +`; + +exports[`attribution validation should provide validation error when attribution url is provided without attribution text 1`] = ` + + + + + + + + + + + +`; + +exports[`should render 1`] = ` + + + + + + + + + + + +`; diff --git a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx index 48c526855d3a4f..b0344a3e0e3181 100644 --- a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx @@ -19,7 +19,12 @@ export const tmsLayerWizardConfig: LayerWizard = { }), icon: 'grid', renderWizard: ({ previewLayers }: RenderWizardArguments) => { - const onSourceConfigChange = (sourceConfig: XYZTMSSourceConfig) => { + const onSourceConfigChange = (sourceConfig: XYZTMSSourceConfig | null) => { + if (!sourceConfig) { + previewLayers([]); + return; + } + const layerDescriptor = TileLayer.createDescriptor({ sourceDescriptor: XYZTMSSource.createDescriptor(sourceConfig), }); diff --git a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/xyz_tms_editor.test.tsx b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/xyz_tms_editor.test.tsx new file mode 100644 index 00000000000000..71f78c3e15152b --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/xyz_tms_editor.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { XYZTMSEditor } from './xyz_tms_editor'; + +const onSourceConfigChange = () => {}; + +test('should render', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +describe('attribution validation', () => { + test('should provide validation error when attribution text is provided without attribution url', () => { + const component = shallow(); + component.setState({ attributionText: 'myAttribtionLabel' }); + expect(component).toMatchSnapshot(); + }); + + test('should provide validation error when attribution url is provided without attribution text', () => { + const component = shallow(); + component.setState({ attributionUrl: 'http://mySource' }); + expect(component).toMatchSnapshot(); + }); + + test('should provide no validation errors when attribution text and attribution url are provided', () => { + const component = shallow(); + component.setState({ attributionText: 'myAttribtionLabel' }); + component.setState({ attributionUrl: 'http://mySource' }); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/xyz_tms_editor.tsx b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/xyz_tms_editor.tsx index bf5f2c3dfe04d5..5583f637b44715 100644 --- a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/xyz_tms_editor.tsx +++ b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/xyz_tms_editor.tsx @@ -9,70 +9,56 @@ import React, { Component, ChangeEvent } from 'react'; import _ from 'lodash'; import { EuiFormRow, EuiFieldText, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { AttributionDescriptor } from '../../../../common/descriptor_types'; -export type XYZTMSSourceConfig = AttributionDescriptor & { +export type XYZTMSSourceConfig = { urlTemplate: string; + attributionText: string; + attributionUrl: string; }; -export interface Props { - onSourceConfigChange: (sourceConfig: XYZTMSSourceConfig) => void; +interface Props { + onSourceConfigChange: (sourceConfig: XYZTMSSourceConfig | null) => void; } interface State { - tmsInput: string; - tmsCanPreview: boolean; + url: string; attributionText: string; attributionUrl: string; } export class XYZTMSEditor extends Component { state = { - tmsInput: '', - tmsCanPreview: false, + url: '', attributionText: '', attributionUrl: '', }; - _sourceConfigChange = _.debounce((updatedSourceConfig: XYZTMSSourceConfig) => { - if (this.state.tmsCanPreview) { - this.props.onSourceConfigChange(updatedSourceConfig); - } - }, 2000); - - _handleTMSInputChange(e: ChangeEvent) { - const url = e.target.value; + _previewLayer = _.debounce(() => { + const { url, attributionText, attributionUrl } = this.state; - const canPreview = + const isUrlValid = url.indexOf('{x}') >= 0 && url.indexOf('{y}') >= 0 && url.indexOf('{z}') >= 0; - this.setState( - { - tmsInput: url, - tmsCanPreview: canPreview, - }, - () => this._sourceConfigChange({ urlTemplate: url }) - ); - } + const sourceConfig = isUrlValid + ? { + urlTemplate: url, + attributionText, + attributionUrl, + } + : null; + this.props.onSourceConfigChange(sourceConfig); + }, 500); - _handleTMSAttributionChange(attributionUpdate: AttributionDescriptor) { - this.setState( - { - attributionUrl: attributionUpdate.attributionUrl || '', - attributionText: attributionUpdate.attributionText || '', - }, - () => { - const { attributionText, attributionUrl, tmsInput } = this.state; + _onUrlChange = (event: ChangeEvent) => { + this.setState({ url: event.target.value }, this._previewLayer); + }; - if (tmsInput && attributionText && attributionUrl) { - this._sourceConfigChange({ - urlTemplate: tmsInput, - attributionText, - attributionUrl, - }); - } - } - ); - } + _onAttributionTextChange = (event: ChangeEvent) => { + this.setState({ attributionText: event.target.value }, this._previewLayer); + }; + + _onAttributionUrlChange = (event: ChangeEvent) => { + this.setState({ attributionUrl: event.target.value }, this._previewLayer); + }; render() { const { attributionText, attributionUrl } = this.state; @@ -81,11 +67,13 @@ export class XYZTMSEditor extends Component { this._handleTMSInputChange(e)} + onChange={this._onUrlChange} /> { }), ]} > - ) => - this._handleTMSAttributionChange({ attributionText: target.value }) - } - /> + { }), ]} > - ) => - this._handleTMSAttributionChange({ attributionUrl: target.value }) - } - /> + ); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js index a3b6cb39815a37..e9f3cb0d7d70d4 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js @@ -63,9 +63,14 @@ export class JobsListView extends Component { this.showDeleteJobModal = () => {}; this.showStartDatafeedModal = () => {}; this.showCreateWatchFlyout = () => {}; + // work around to keep track of whether the component is mounted + // used to block timeouts for results polling + // which can run after unmounting + this._isMounted = false; } componentDidMount() { + this._isMounted = true; this.refreshJobSummaryList(true); if (this.props.isManagementTable !== true) { @@ -87,6 +92,7 @@ export class JobsListView extends Component { if (this.props.isManagementTable === undefined) { deletingJobsRefreshTimeout = null; } + this._isMounted = false; } openAutoStartDatafeedModal() { @@ -232,7 +238,7 @@ export class JobsListView extends Component { }; async refreshJobSummaryList(forceRefresh = false) { - if (forceRefresh === true || this.props.blockRefresh !== true) { + if (this._isMounted && (forceRefresh === true || this.props.blockRefresh !== true)) { // Set loading to true for jobs_list table for initial job loading if (this.state.loading === null) { this.setState({ loading: true }); @@ -283,6 +289,10 @@ export class JobsListView extends Component { } async checkDeletingJobTasks(forceRefresh = false) { + if (this._isMounted === false) { + return; + } + const { jobIds: taskJobIds } = await ml.jobs.deletingJobTasks(); const taskListHasChanged = diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/job_settings_form.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/job_settings_form.tsx index 63dec536ea487a..e31c6bc7b59e04 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/job_settings_form.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/job_settings_form.tsx @@ -258,7 +258,7 @@ export const JobSettingsForm: FC = ({ fill type="submit" isLoading={saveState === SAVE_STATE.SAVING} - disabled={!validationResult.formValid} + disabled={!validationResult.formValid || saveState === SAVE_STATE.SAVING} onClick={() => { onSubmit(formState); }} @@ -266,19 +266,11 @@ export const JobSettingsForm: FC = ({ defaultMessage: 'Create job', })} > - {saveState === SAVE_STATE.NOT_SAVED && ( - - )} - {saveState === SAVE_STATE.SAVING && ( - - )} + diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/operators.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/operators.ts index a81d8cde94e34f..c54f58a3fd4b36 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/operators.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/operators.ts @@ -90,3 +90,17 @@ export const EXCEPTION_OPERATORS: OperatorOption[] = [ isInListOperator, isNotInListOperator, ]; + +export const EXCEPTION_OPERATORS_SANS_LISTS: OperatorOption[] = [ + isOperator, + isNotOperator, + isOneOfOperator, + isNotOneOfOperator, + existsOperator, + doesNotExistOperator, +]; + +export const EXCEPTION_OPERATORS_ONLY_LISTS: OperatorOption[] = [ + isInListOperator, + isNotInListOperator, +]; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index 0d93a1ea88714a..a4fe52eaacf4e1 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -276,8 +276,8 @@ export const AddExceptionModal = memo(function AddExceptionModal({ signalIndexName, ]); - const isSubmitButtonDisabled = useCallback( - () => fetchOrCreateListError || exceptionItemsToAdd.length === 0, + const isSubmitButtonDisabled = useMemo( + () => fetchOrCreateListError || exceptionItemsToAdd.every((item) => item.entries.length === 0), [fetchOrCreateListError, exceptionItemsToAdd] ); @@ -296,9 +296,13 @@ export const AddExceptionModal = memo(function AddExceptionModal({

{i18n.ADD_EXCEPTION_FETCH_ERROR}

)} - {fetchOrCreateListError === false && isLoadingExceptionList === true && ( - - )} + {fetchOrCreateListError === false && + (isLoadingExceptionList || + isIndexPatternLoading || + isSignalIndexLoading || + isSignalIndexPatternLoading) && ( + + )} {fetchOrCreateListError === false && !isSignalIndexLoading && !isSignalIndexPatternLoading && @@ -373,7 +377,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ {i18n.ADD_EXCEPTION} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx index b845848bd14d8c..3dcc3eb5a8329f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx @@ -213,7 +213,7 @@ describe('BuilderEntryItem', () => { title: 'logstash-*', fields, }} - showLabel={false} + showLabel={true} listType="detection" addNested={false} onChange={jest.fn()} @@ -245,7 +245,7 @@ describe('BuilderEntryItem', () => { title: 'logstash-*', fields, }} - showLabel={false} + showLabel={true} listType="detection" addNested={false} onChange={jest.fn()} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx index 736e88ee9fe065..dcc8a0e4fb1ba3 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx @@ -27,6 +27,7 @@ import { getEntryOnMatchAnyChange, getEntryOnListChange, } from './helpers'; +import { EXCEPTION_OPERATORS_ONLY_LISTS } from '../../autocomplete/operators'; interface EntryItemProps { entry: FormattedBuilderEntry; @@ -35,6 +36,7 @@ interface EntryItemProps { listType: ExceptionListType; addNested: boolean; onChange: (arg: BuilderEntry, i: number) => void; + onlyShowListOperators?: boolean; } export const BuilderEntryItem: React.FC = ({ @@ -44,6 +46,7 @@ export const BuilderEntryItem: React.FC = ({ addNested, showLabel, onChange, + onlyShowListOperators = false, }): JSX.Element => { const handleFieldChange = useCallback( ([newField]: IFieldType[]): void => { @@ -124,11 +127,14 @@ export const BuilderEntryItem: React.FC = ({ ); const renderOperatorInput = (isFirst: boolean): JSX.Element => { - const operatorOptions = getOperatorOptions( - entry, - listType, - entry.field != null && entry.field.type === 'boolean' - ); + const operatorOptions = onlyShowListOperators + ? EXCEPTION_OPERATORS_ONLY_LISTS + : getOperatorOptions( + entry, + listType, + entry.field != null && entry.field.type === 'boolean', + isFirst + ); const comboBox = ( void; onChangeExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void; + onlyShowListOperators?: boolean; } export const ExceptionListItemComponent = React.memo( @@ -58,6 +59,7 @@ export const ExceptionListItemComponent = React.memo( andLogicIncluded, onDeleteExceptionItem, onChangeExceptionItem, + onlyShowListOperators = false, }) => { const handleEntryChange = useCallback( (entry: BuilderEntry, entryIndex: number): void => { @@ -169,6 +171,7 @@ export const ExceptionListItemComponent = React.memo( exceptionItemIndex === 0 && index === 0 && item.nested !== 'child' } onChange={handleEntryChange} + onlyShowListOperators={onlyShowListOperators} /> {getDeleteButton( diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx index 8b74d44f29a184..17c94adf42648f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx @@ -14,32 +14,33 @@ import { getEntryExistsMock } from '../../../../../../lists/common/schemas/types import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { getListResponseMock } from '../../../../../../lists/common/schemas/response/list_schema.mock'; import { - isOperator, - isOneOfOperator, - isNotOperator, - isNotOneOfOperator, - existsOperator, doesNotExistOperator, - isInListOperator, EXCEPTION_OPERATORS, + EXCEPTION_OPERATORS_SANS_LISTS, + existsOperator, + isInListOperator, + isNotOneOfOperator, + isNotOperator, + isOneOfOperator, + isOperator, } from '../../autocomplete/operators'; -import { FormattedBuilderEntry, BuilderEntry, ExceptionsBuilderExceptionItem } from '../types'; -import { IIndexPattern, IFieldType } from '../../../../../../../../src/plugins/data/common'; -import { EntryNested, Entry } from '../../../../lists_plugin_deps'; +import { BuilderEntry, ExceptionsBuilderExceptionItem, FormattedBuilderEntry } from '../types'; +import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; +import { Entry, EntryNested } from '../../../../lists_plugin_deps'; import { - getFilteredIndexPatterns, - getFormattedBuilderEntry, - isEntryNested, - getFormattedBuilderEntries, - getUpdatedEntriesOnDelete, getEntryFromOperator, - getOperatorOptions, getEntryOnFieldChange, - getEntryOnOperatorChange, - getEntryOnMatchChange, - getEntryOnMatchAnyChange, getEntryOnListChange, + getEntryOnMatchAnyChange, + getEntryOnMatchChange, + getEntryOnOperatorChange, + getFilteredIndexPatterns, + getFormattedBuilderEntries, + getFormattedBuilderEntry, + getOperatorOptions, + getUpdatedEntriesOnDelete, + isEntryNested, } from './helpers'; import { OperatorOption } from '../../autocomplete/types'; @@ -672,6 +673,18 @@ describe('Exception builder helpers', () => { const expected: OperatorOption[] = [isOperator, existsOperator]; expect(output).toEqual(expected); }); + + test('it returns list operators if specified to', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', false, true); + expect(output).toEqual(EXCEPTION_OPERATORS); + }); + + test('it does not return list operators if specified not to', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', false, false); + expect(output).toEqual(EXCEPTION_OPERATORS_SANS_LISTS); + }); }); describe('#getEntryOnFieldChange', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx index 2fe2c68941ae66..93bae091885c14 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx @@ -22,6 +22,7 @@ import { existsOperator, isOneOfOperator, EXCEPTION_OPERATORS, + EXCEPTION_OPERATORS_SANS_LISTS, } from '../../autocomplete/operators'; import { OperatorOption } from '../../autocomplete/types'; import { @@ -40,7 +41,6 @@ import { getEntryValue, getExceptionOperatorSelect } from '../helpers'; * * @param patterns IIndexPattern containing available fields on rule index * @param item exception item entry - * @param addNested boolean noting whether or not UI is currently * set to add a nested field */ export const getFilteredIndexPatterns = ( @@ -295,12 +295,14 @@ export const getEntryFromOperator = ( * * @param item * @param listType - * + * @param isBoolean + * @param includeValueListOperators whether or not to include the 'is in list' and 'is not in list' operators */ export const getOperatorOptions = ( item: FormattedBuilderEntry, listType: ExceptionListType, - isBoolean: boolean + isBoolean: boolean, + includeValueListOperators = true ): OperatorOption[] => { if (item.nested === 'parent' || item.field == null) { return [isOperator]; @@ -309,7 +311,11 @@ export const getOperatorOptions = ( } else if (item.nested != null && listType === 'detection') { return isBoolean ? [isOperator, existsOperator] : [isOperator, isOneOfOperator, existsOperator]; } else { - return isBoolean ? [isOperator, existsOperator] : EXCEPTION_OPERATORS; + return isBoolean + ? [isOperator, existsOperator] + : includeValueListOperators + ? EXCEPTION_OPERATORS + : EXCEPTION_OPERATORS_SANS_LISTS; } }; @@ -547,3 +553,6 @@ export const getDefaultNestedEmptyEntry = (): EmptyNestedEntry => ({ type: OperatorTypeEnum.NESTED, entries: [], }); + +export const containsValueListEntry = (items: ExceptionsBuilderExceptionItem[]): boolean => + items.some((item) => item.entries.some((entry) => entry.type === OperatorTypeEnum.LIST)); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx index 141429f152790a..1ec49425ce8fd0 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx @@ -24,7 +24,11 @@ import { BuilderButtonOptions } from './builder_button_options'; import { getNewExceptionItem, filterExceptionItems } from '../helpers'; import { ExceptionsBuilderExceptionItem, CreateExceptionListItemBuilderSchema } from '../types'; import { State, exceptionsBuilderReducer } from './reducer'; -import { getDefaultEmptyEntry, getDefaultNestedEmptyEntry } from './helpers'; +import { + containsValueListEntry, + getDefaultEmptyEntry, + getDefaultNestedEmptyEntry, +} from './helpers'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import exceptionableFields from '../exceptionable_fields.json'; @@ -44,6 +48,7 @@ const MyButtonsContainer = styled(EuiFlexItem)` const initialState: State = { disableAnd: false, + disableNested: false, disableOr: false, andLogicIncluded: false, addNested: false, @@ -82,12 +87,21 @@ export const ExceptionBuilder = ({ onChange, }: ExceptionBuilderProps) => { const [ - { exceptions, exceptionsToDelete, andLogicIncluded, disableAnd, disableOr, addNested }, + { + exceptions, + exceptionsToDelete, + andLogicIncluded, + disableAnd, + disableNested, + disableOr, + addNested, + }, dispatch, ] = useReducer(exceptionsBuilderReducer(), { ...initialState, disableAnd: isAndDisabled, disableOr: isOrDisabled, + disableNested: isNestedDisabled, }); const setUpdateExceptions = useCallback( @@ -362,6 +376,7 @@ export const ExceptionBuilder = ({ isOnlyItem={exceptions.length === 1} onDeleteExceptionItem={handleDeleteExceptionItem} onChangeExceptionItem={handleExceptionItemChange} + onlyShowListOperators={containsValueListEntry(exceptions)} /> @@ -379,7 +394,7 @@ export const ExceptionBuilder = ({ (state: State, action: Action): St const isAndDisabled = lastEntry != null && lastEntry.type === 'nested' && lastEntry.entries.length === 0; const isOrDisabled = lastEntry != null && lastEntry.type === 'nested'; + const containsValueList = action.exceptions.some( + ({ entries }) => entries.filter(({ type }) => type === OperatorTypeEnum.LIST).length > 0 + ); return { ...state, @@ -67,6 +71,7 @@ export const exceptionsBuilderReducer = () => (state: State, action: Action): St addNested: isAddNested, disableAnd: isAndDisabled, disableOr: isOrDisabled, + disableNested: containsValueList, }; } case 'setDefault': { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index 4ad077edf66fff..47c3498cb6ab4d 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { memo, useState, useCallback, useEffect } from 'react'; +import React, { memo, useState, useCallback, useEffect, useMemo } from 'react'; import styled, { css } from 'styled-components'; import { EuiModal, @@ -146,6 +146,11 @@ export const EditExceptionModal = memo(function EditExceptionModal({ } }, [shouldDisableBulkClose]); + const isSubmitButtonDisabled = useMemo( + () => exceptionItemsToAdd.every((item) => item.entries.length === 0), + [exceptionItemsToAdd] + ); + const handleBuilderOnChange = useCallback( ({ exceptionItems, @@ -261,7 +266,12 @@ export const EditExceptionModal = memo(function EditExceptionModal({ {i18n.CANCEL} - + {i18n.EDIT_EXCEPTION_SAVE_BUTTON} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 384badefc34aa8..a54f20f56d56f7 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -383,6 +383,7 @@ export const defaultEndpointExceptionItems = ( fieldName: 'file.Ext.code_signature.trusted', }); const [sha1Hash] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.hash.sha1' }); + const [eventCode] = getMappedNonEcsValue({ data: alertData, fieldName: 'event.code' }); const namespaceType = 'agnostic'; return [ @@ -390,49 +391,40 @@ export const defaultEndpointExceptionItems = ( ...getNewExceptionItem({ listType, listId, namespaceType, ruleName }), entries: [ { - field: 'file.path', - operator: 'included', - type: 'match', - value: filePath ?? '', - }, - ], - }, - { - ...getNewExceptionItem({ listType, listId, namespaceType, ruleName }), - entries: [ - { - field: 'file.Ext.code_signature.subject_name', - operator: 'included', - type: 'match', - value: signatureSigner ?? '', + field: 'file.Ext.code_signature', + type: 'nested', + entries: [ + { + field: 'subject_name', + operator: 'included', + type: 'match', + value: signatureSigner ?? '', + }, + { + field: 'trusted', + operator: 'included', + type: 'match', + value: signatureTrusted ?? '', + }, + ], }, { - field: 'file.Ext.code_signature.trusted', + field: 'file.path', operator: 'included', type: 'match', - value: signatureTrusted ?? '', + value: filePath ?? '', }, - ], - }, - { - ...getNewExceptionItem({ listType, listId, namespaceType, ruleName }), - entries: [ { field: 'file.hash.sha1', operator: 'included', type: 'match', value: sha1Hash ?? '', }, - ], - }, - { - ...getNewExceptionItem({ listType, listId, namespaceType, ruleName }), - entries: [ { - field: 'event.category', + field: 'event.code', operator: 'included', - type: 'match_any', - value: getMappedNonEcsValue({ data: alertData, fieldName: 'event.category' }), + type: 'match', + value: eventCode ?? '', }, ], }, diff --git a/x-pack/plugins/security_solution/public/common/components/help_menu/index.tsx b/x-pack/plugins/security_solution/public/common/components/help_menu/index.tsx index f4477740f7b584..1eaa16fd058a5f 100644 --- a/x-pack/plugins/security_solution/public/common/components/help_menu/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/help_menu/index.tsx @@ -39,7 +39,7 @@ export const HelpMenu = React.memo(() => { }, { linkType: 'discuss', - href: 'https://discuss.elastic.co/c/siem', + href: 'https://discuss.elastic.co/c/security', target: '_blank', rel: 'noopener', }, diff --git a/x-pack/plugins/security_solution/public/common/components/news_feed/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/news_feed/helpers.test.ts index cdd04b50a6d506..35a59f4d18e8b9 100644 --- a/x-pack/plugins/security_solution/public/common/components/news_feed/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/news_feed/helpers.test.ts @@ -144,7 +144,7 @@ describe('helpers', () => { hash: '5a35c984a9cdc1c6a25913f3d0b99b1aefc7257bc3b936c39db9fa0435edeed0', imageUrl: 'https://aws1.discourse-cdn.com/elastic/original/3X/f/8/f8c3d0b9971cfcd0be349d973aa5799f71d280cc.png?blade=securitysolutionfeed', - linkUrl: 'https://discuss.elastic.co/c/siem?blade=securitysolutionfeed', + linkUrl: 'https://discuss.elastic.co/c/security?blade=securitysolutionfeed', publishOn: expect.any(Date), title: 'Got SIEM Questions?', }, @@ -284,7 +284,7 @@ describe('helpers', () => { }, link_text: null, link_url: { - en: 'https://discuss.elastic.co/c/siem?blade=securitysolutionfeed', + en: 'https://discuss.elastic.co/c/security?blade=securitysolutionfeed', ja: translatedLinkUrl, }, languages: null, diff --git a/x-pack/plugins/security_solution/public/common/mock/news.ts b/x-pack/plugins/security_solution/public/common/mock/news.ts index 3e421ce19ae9c5..51449347e649ae 100644 --- a/x-pack/plugins/security_solution/public/common/mock/news.ts +++ b/x-pack/plugins/security_solution/public/common/mock/news.ts @@ -16,7 +16,7 @@ export const rawNewsApiResponse: RawNewsApiResponse = { "There's an awesome community of Elastic SIEM users out there. Join the discussion about configuring, learning, and using the Elastic SIEM app, and detecting threats!", }, link_text: null, - link_url: { en: 'https://discuss.elastic.co/c/siem?blade=securitysolutionfeed' }, + link_url: { en: 'https://discuss.elastic.co/c/security?blade=securitysolutionfeed' }, languages: null, badge: { en: '7.6' }, image_url: { diff --git a/x-pack/plugins/security_solution/public/common/mock/raw_news.ts b/x-pack/plugins/security_solution/public/common/mock/raw_news.ts index 85bef15a41b230..9cd06ed1079567 100644 --- a/x-pack/plugins/security_solution/public/common/mock/raw_news.ts +++ b/x-pack/plugins/security_solution/public/common/mock/raw_news.ts @@ -17,7 +17,7 @@ export const rawNewsJSON = ` }, "link_text":null, "link_url":{ - "en":"https://discuss.elastic.co/c/siem?blade=securitysolutionfeed" + "en":"https://discuss.elastic.co/c/security?blade=securitysolutionfeed" }, "languages":null, "badge":{ diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index a4ce6c0200eb3c..010129d2d45933 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -204,6 +204,7 @@ export const requiredFieldsForActions = [ 'file.Ext.code_signature.trusted', 'file.hash.sha1', 'host.os.family', + 'event.code', ]; interface AlertActionArgs { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.test.ts index 32f96b519acc5f..1cbd1ee0f76ae9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.test.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.test.ts @@ -23,6 +23,6 @@ describe('getBreadcrumbs', () => { [], getUrlForAppMock ) - ).toEqual([{ href: 'securitySolution:detections', text: 'Detection alerts' }]); + ).toEqual([{ href: 'securitySolution:detections', text: 'Detections' }]); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts index 75d1df9406d25e..c1b4fa3e2b7d9b 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts @@ -57,7 +57,7 @@ export const getBreadcrumbs = ( ): ChromeBreadcrumb[] => { let breadcrumb = [ { - text: i18nDetections.PAGE_TITLE, + text: i18nDetections.BREADCRUMB_TITLE, href: getUrlForApp(`${APP_ID}:${SecurityPageName.detections}`, { path: !isEmpty(search[0]) ? search[0] : '', }), diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts index 92dc02ac8478cc..10223716ef331a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts @@ -6,6 +6,13 @@ import { i18n } from '@kbn/i18n'; +export const BREADCRUMB_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.detectionsBreadcrumbTitle', + { + defaultMessage: 'Detections', + } +); + export const PAGE_TITLE = i18n.translate( 'xpack.securitySolution.detectionEngine.detectionsPageTitle', { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index 8612b15f898572..4f7c14735fe210 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -232,7 +232,7 @@ describe('Policy Details', () => { ); expect(warningCallout).toHaveLength(1); expect(warningCallout.text()).toEqual( - 'This action will update 5 hostsSaving these changes will apply the updates to all active endpoints assigned to this policy' + 'This action will update 5 hostsSaving these changes will apply updates to all endpoints assigned to this policy' ); }); it('should close dialog if cancel button is clicked', () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx index 9576e1aedcaf1a..288bc484c23b51 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx @@ -306,7 +306,7 @@ const ConfirmUpdate = React.memo<{ > diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/config_form.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/config_form.tsx index 763931bc2d3d75..8e3c4138efb36e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/config_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/config_form.tsx @@ -34,17 +34,10 @@ export const ConfigForm: React.FC<{ */ supportedOss: React.ReactNode; children: React.ReactNode; - /** - * A description for the component. - */ - description: string; - /** - * The `data-test-subj` attribute to append to a certain child element. - */ dataTestSubj: string; /** React Node to be put on the right corner of the card */ rightCorner: React.ReactNode; -}> = React.memo(({ type, supportedOss, children, dataTestSubj, rightCorner, description }) => { +}> = React.memo(({ type, supportedOss, children, dataTestSubj, rightCorner }) => { const typeTitle = useMemo(() => { return ( @@ -85,12 +78,7 @@ export const ConfigForm: React.FC<{ return ( - + {children} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx index d7bae0d2e6bade..66126adb7a4e1d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx @@ -102,12 +102,6 @@ export const LinuxEvents = React.memo(() => { type={i18n.translate('xpack.securitySolution.endpoint.policy.details.eventCollection', { defaultMessage: 'Event Collection', })} - description={i18n.translate( - 'xpack.securitySolution.endpoint.policy.details.eventCollectionLabel', - { - defaultMessage: 'Event Collection', - } - )} supportedOss={i18n.translate('xpack.securitySolution.endpoint.policy.details.linux', { defaultMessage: 'Linux', })} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx index 37709ff608857a..dc70fc0ba0f4fd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx @@ -102,12 +102,6 @@ export const MacEvents = React.memo(() => { type={i18n.translate('xpack.securitySolution.endpoint.policy.details.eventCollection', { defaultMessage: 'Event Collection', })} - description={i18n.translate( - 'xpack.securitySolution.endpoint.policy.details.eventCollectionLabel', - { - defaultMessage: 'Event Collection', - } - )} supportedOss={i18n.translate('xpack.securitySolution.endpoint.policy.details.mac', { defaultMessage: 'Mac', })} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx index 3c7ecae0d9b4eb..5acdf67922a3a6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx @@ -142,9 +142,6 @@ export const WindowsEvents = React.memo(() => { type={i18n.translate('xpack.securitySolution.endpoint.policy.details.eventCollection', { defaultMessage: 'Event Collection', })} - description={i18n.translate('xpack.securitySolution.endpoint.policy.details.windowsLabel', { - defaultMessage: 'Windows', - })} supportedOss={i18n.translate('xpack.securitySolution.endpoint.policy.details.windows', { defaultMessage: 'Windows', })} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx index 23ac6cc5b813d4..dee1e27782e691 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx @@ -174,9 +174,6 @@ export const MalwareProtections = React.memo(() => { defaultMessage: 'Windows, Mac', })} dataTestSubj="malwareProtectionsForm" - description={i18n.translate('xpack.securitySolution.endpoint.policy.details.malwareLabel', { - defaultMessage: 'Malware', - })} rightCorner={protectionSwitch} > {radioButtons} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx index 20b6534f7664e3..667aacd9df3bf9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx @@ -322,9 +322,8 @@ export const PolicyList = React.memo(() => { }), render(pkg: Immutable) { return i18n.translate('xpack.securitySolution.endpoint.policyList.versionField', { - defaultMessage: '{title} v{version}', + defaultMessage: 'v{version}', values: { - title: pkg.title, version: pkg.version, }, }); diff --git a/x-pack/plugins/security_solution/public/overview/pages/summary.tsx b/x-pack/plugins/security_solution/public/overview/pages/summary.tsx index 0f20e8bea9dc54..d8260858aa2457 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/summary.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/summary.tsx @@ -71,7 +71,7 @@ export const Summary = React.memo(() => { defaultMessage="If you have input or suggestions regarding your experience with Elastic SIEM, please feel free to {feedback}." values={{ feedback: ( - + { const initialTime = new Date('6/1/2020').getTime(); + const oneMillisecond = new Date(initialTime + 1).getTime(); const oneSecond = new Date(initialTime + 1 * second).getTime(); const oneMinute = new Date(initialTime + 1 * minute).getTime(); const oneHour = new Date(initialTime + 1 * hour).getTime(); @@ -25,6 +26,7 @@ describe('date', () => { const oneMonth = new Date(initialTime + 1 * month).getTime(); const oneYear = new Date(initialTime + 1 * year).getTime(); + const almostASecond = new Date(initialTime + 999).getTime(); const almostAMinute = new Date(initialTime + 59.9 * second).getTime(); const almostAnHour = new Date(initialTime + 59.9 * minute).getTime(); const almostADay = new Date(initialTime + 23.9 * hour).getTime(); @@ -34,6 +36,14 @@ describe('date', () => { const threeYears = new Date(initialTime + 3 * year).getTime(); it('should return the correct singular relative time', () => { + expect(getFriendlyElapsedTime(initialTime, initialTime)).toEqual({ + duration: '<1', + durationType: 'millisecond', + }); + expect(getFriendlyElapsedTime(initialTime, oneMillisecond)).toEqual({ + duration: 1, + durationType: 'millisecond', + }); expect(getFriendlyElapsedTime(initialTime, oneSecond)).toEqual({ duration: 1, durationType: 'second', @@ -65,6 +75,10 @@ describe('date', () => { }); it('should return the correct pluralized relative time', () => { + expect(getFriendlyElapsedTime(initialTime, almostASecond)).toEqual({ + duration: 999, + durationType: 'milliseconds', + }); expect(getFriendlyElapsedTime(initialTime, almostAMinute)).toEqual({ duration: 59, durationType: 'seconds', diff --git a/x-pack/plugins/security_solution/public/resolver/lib/date.ts b/x-pack/plugins/security_solution/public/resolver/lib/date.ts index de0f9dcd7efbea..a5e07e6a02a885 100644 --- a/x-pack/plugins/security_solution/public/resolver/lib/date.ts +++ b/x-pack/plugins/security_solution/public/resolver/lib/date.ts @@ -18,7 +18,6 @@ export const getFriendlyElapsedTime = ( const startTime = typeof from === 'number' ? from : parseInt(from, 10); const endTime = typeof to === 'number' ? to : parseInt(to, 10); const elapsedTimeInMs = endTime - startTime; - if (Number.isNaN(elapsedTimeInMs)) { return null; } @@ -31,45 +30,50 @@ export const getFriendlyElapsedTime = ( const month = day * 30; const year = day * 365; - let duration: number; + let duration: DurationDetails['duration']; let singularType: DurationTypes; let pluralType: DurationTypes; switch (true) { case elapsedTimeInMs >= year: - duration = elapsedTimeInMs / year; + duration = Math.floor(elapsedTimeInMs / year); singularType = 'year'; pluralType = 'years'; break; case elapsedTimeInMs >= month: - duration = elapsedTimeInMs / month; + duration = Math.floor(elapsedTimeInMs / month); singularType = 'month'; pluralType = 'months'; break; case elapsedTimeInMs >= week: - duration = elapsedTimeInMs / week; + duration = Math.floor(elapsedTimeInMs / week); singularType = 'week'; pluralType = 'weeks'; break; case elapsedTimeInMs >= day: - duration = elapsedTimeInMs / day; + duration = Math.floor(elapsedTimeInMs / day); singularType = 'day'; pluralType = 'days'; break; case elapsedTimeInMs >= hour: - duration = elapsedTimeInMs / hour; + duration = Math.floor(elapsedTimeInMs / hour); singularType = 'hour'; pluralType = 'hours'; break; case elapsedTimeInMs >= minute: - duration = elapsedTimeInMs / minute; + duration = Math.floor(elapsedTimeInMs / minute); singularType = 'minute'; pluralType = 'minutes'; break; case elapsedTimeInMs >= second: - duration = elapsedTimeInMs / second; + duration = Math.floor(elapsedTimeInMs / second); singularType = 'second'; pluralType = 'seconds'; break; + case elapsedTimeInMs === 0: + duration = '<1'; + singularType = 'millisecond'; + pluralType = 'millisecond'; // Would never show + break; default: duration = elapsedTimeInMs; singularType = 'millisecond'; @@ -77,6 +81,6 @@ export const getFriendlyElapsedTime = ( break; } - const durationType = duration > 1 ? pluralType : singularType; - return { duration: Math.floor(duration), durationType }; + const durationType = duration === 1 ? singularType : pluralType; + return { duration, durationType }; }; diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts index 683f8f1a5f84a5..9e1c396723a274 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts @@ -12,6 +12,7 @@ import { createStore } from 'redux'; import { mockTreeWithNoAncestorsAnd2Children, mockTreeWith2AncestorsAndNoChildren, + mockTreeWith1AncestorAnd2ChildrenAndAllNodesHave2GraphableEvents, } from '../mocks/resolver_tree'; import { uniquePidForProcess } from '../../models/process_event'; import { EndpointEvent } from '../../../../common/endpoint/types'; @@ -353,4 +354,29 @@ describe('data state', () => { } }); }); + describe('with a tree with 1 ancestor and 2 children, where all nodes have 2 graphable events', () => { + const ancestorID = 'b'; + const originID = 'c'; + const firstChildID = 'd'; + const secondChildID = 'e'; + beforeEach(() => { + const tree = mockTreeWith1AncestorAnd2ChildrenAndAllNodesHave2GraphableEvents({ + ancestorID, + originID, + firstChildID, + secondChildID, + }); + actions.push({ + type: 'serverReturnedResolverData', + payload: { + result: tree, + // this value doesn't matter + databaseDocumentID: '', + }, + }); + }); + it('should have 4 graphable processes', () => { + expect(selectors.graphableProcesses(state()).length).toBe(4); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 40138d3f2fd3c1..1d65b406306a36 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -109,8 +109,16 @@ export const terminatedProcesses = createSelector(resolverTreeResponse, function * Process events that will be graphed. */ export const graphableProcesses = createSelector(resolverTreeResponse, function (tree?) { + // Keep track of the last process event (in array order) for each entity ID + const events: Map = new Map(); if (tree) { - return resolverTreeModel.lifecycleEvents(tree).filter(isGraphableProcess); + for (const event of resolverTreeModel.lifecycleEvents(tree)) { + if (isGraphableProcess(event)) { + const entityID = uniquePidForProcess(event); + events.set(entityID, event); + } + } + return [...events.values()]; } else { return []; } diff --git a/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts b/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts index 862cf47f73947d..2860eec5a6ab6d 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts @@ -85,3 +85,81 @@ export function mockTreeWithNoAncestorsAnd2Children({ lifecycle: [origin], } as unknown) as ResolverTree; } + +/** + * Creates a mock tree w/ 2 'graphable' events per node. This simulates the scenario where data has been duplicated in the response from the server. + */ +export function mockTreeWith1AncestorAnd2ChildrenAndAllNodesHave2GraphableEvents({ + ancestorID, + originID, + firstChildID, + secondChildID, +}: { + ancestorID: string; + originID: string; + firstChildID: string; + secondChildID: string; +}): ResolverTree { + const ancestor: ResolverEvent = mockEndpointEvent({ + entityID: ancestorID, + name: ancestorID, + timestamp: 1, + parentEntityId: undefined, + }); + const ancestorClone: ResolverEvent = mockEndpointEvent({ + entityID: ancestorID, + name: ancestorID, + timestamp: 1, + parentEntityId: undefined, + }); + const origin: ResolverEvent = mockEndpointEvent({ + entityID: originID, + name: originID, + parentEntityId: ancestorID, + timestamp: 0, + }); + const originClone: ResolverEvent = mockEndpointEvent({ + entityID: originID, + name: originID, + parentEntityId: ancestorID, + timestamp: 0, + }); + const firstChild: ResolverEvent = mockEndpointEvent({ + entityID: firstChildID, + name: firstChildID, + parentEntityId: originID, + timestamp: 1, + }); + const firstChildClone: ResolverEvent = mockEndpointEvent({ + entityID: firstChildID, + name: firstChildID, + parentEntityId: originID, + timestamp: 1, + }); + const secondChild: ResolverEvent = mockEndpointEvent({ + entityID: secondChildID, + name: secondChildID, + parentEntityId: originID, + timestamp: 2, + }); + const secondChildClone: ResolverEvent = mockEndpointEvent({ + entityID: secondChildID, + name: secondChildID, + parentEntityId: originID, + timestamp: 2, + }); + + return ({ + entityID: originID, + children: { + childNodes: [ + { lifecycle: [firstChild, firstChildClone] }, + { lifecycle: [secondChild, secondChildClone] }, + ], + }, + ancestry: { + ancestors: [{ lifecycle: [ancestor, ancestorClone] }], + }, + lifecycle: [origin, originClone], + } as unknown) as ResolverTree; +} diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 856ae2d6240e3f..02a890ca13ee8e 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -310,7 +310,7 @@ export type DurationTypes = * duration value and description string */ export interface DurationDetails { - duration: number; + duration: number | '<1'; durationType: DurationTypes; } /** diff --git a/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx b/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx index 65c70f94432c79..9f310bb1cc0d65 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx @@ -45,7 +45,7 @@ const StyledElapsedTime = styled.div` left: ${(props) => `${props.leftPct}%`}; padding: 6px 8px; border-radius: 999px; // generate pill shape - transform: translate(-50%, -50%) rotateX(35deg); + transform: translate(-50%, -50%); user-select: none; `; diff --git a/x-pack/plugins/security_solution/public/resolver/view/map.tsx b/x-pack/plugins/security_solution/public/resolver/view/map.tsx index 69ff9c8e2351b3..30aa4b63a138d6 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/map.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/map.tsx @@ -10,6 +10,7 @@ import React, { useContext } from 'react'; import { useSelector } from 'react-redux'; +import { useEffectOnce } from 'react-use'; import { EuiLoadingSpinner } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import * as selectors from '../store/selectors'; @@ -19,6 +20,7 @@ import { ProcessEventDot } from './process_event_dot'; import { useCamera } from './use_camera'; import { SymbolDefinitions, useResolverTheme } from './assets'; import { useStateSyncingActions } from './use_state_syncing_actions'; +import { useResolverQueryParams } from './use_resolver_query_params'; import { StyledMapContainer, StyledPanel, GraphContainer } from './styles'; import { entityId } from '../../../common/endpoint/models/event'; import { SideEffectContext } from './side_effect_context'; @@ -66,6 +68,10 @@ export const ResolverMap = React.memo(function ({ const hasError = useSelector(selectors.hasError); const activeDescendantId = useSelector(selectors.ariaActiveDescendant); const { colorMap } = useResolverTheme(); + const { cleanUpQueryParams } = useResolverQueryParams(); + useEffectOnce(() => { + return () => cleanUpQueryParams(); + }); return ( diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts index 3c342ae575aa05..84d954de6ef274 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts @@ -63,8 +63,19 @@ export function useResolverQueryParams() { }; }, [urlSearch, uniqueCrumbIdKey, uniqueCrumbEventKey]); + const cleanUpQueryParams = () => { + const crumbsToPass = { + ...querystring.parse(urlSearch.slice(1)), + }; + delete crumbsToPass[uniqueCrumbIdKey]; + delete crumbsToPass[uniqueCrumbEventKey]; + const relativeURL = { search: querystring.stringify(crumbsToPass) }; + history.replace(relativeURL); + }; + return { pushToQueryParams, queryParams, + cleanUpQueryParams, }; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts index fe7a8296608d21..084f892369b519 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts @@ -201,7 +201,11 @@ async function findAgent( hostMetadata.elastic.agent.id ); } catch (e) { - if (e.isBoom && e.output.statusCode === 404) { + if ( + metadataRequestContext.requestHandlerContext.core.savedObjects.client.errors.isNotFoundError( + e + ) + ) { metadataRequestContext.logger.warn( `agent with id ${hostMetadata.elastic.agent.id} not found` ); @@ -264,7 +268,11 @@ async function enrichHostMetadata( ); hostStatus = HOST_STATUS_MAPPING.get(status) || HostStatus.ERROR; } catch (e) { - if (e.isBoom && e.output.statusCode === 404) { + if ( + metadataRequestContext.requestHandlerContext.core.savedObjects.client.errors.isNotFoundError( + e + ) + ) { log.warn(`agent with id ${elasticAgentId} not found`); } else { log.error(e); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index 8d967656065d1c..f3b832de9a7862 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -12,6 +12,7 @@ import { RouteConfig, SavedObjectsClientContract, } from 'kibana/server'; +import { SavedObjectsErrorHelpers } from '../../../../../../../src/core/server/'; import { elasticsearchServiceMock, httpServerMock, @@ -31,7 +32,6 @@ import { createMockEndpointAppContextServiceStartContract, createRouteHandlerContext, } from '../../mocks'; -import Boom from 'boom'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; @@ -306,11 +306,11 @@ describe('test endpoint route', () => { }); 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 13ca51e1f2b395..b52c51ba789af2 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/task_manager/server/lib/bulk_operation_buffer.test.ts b/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts index 3a21f622cec173..f32a755515a953 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]) => { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 846330146cf07c..cf789d1e7c4508 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10239,7 +10239,6 @@ "xpack.ml.newJob.recognize.advancedLabel": "高度な設定", "xpack.ml.newJob.recognize.advancedSettingsAriaLabel": "高度な設定", "xpack.ml.newJob.recognize.alreadyExistsLabel": "(既に存在します)", - "xpack.ml.newJob.recognize.analysisRunningLabel": "分析を実行中", "xpack.ml.newJob.recognize.cancelJobOverrideLabel": "閉じる", "xpack.ml.newJob.recognize.createJobButtonAriaLabel": "ジョブを作成", "xpack.ml.newJob.recognize.createJobButtonLabel": "{numberOfJobs, plural, zero {Job} one {Job} other {Jobs}} を作成", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 477858d2e74d10..5b81804faf7150 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10244,7 +10244,6 @@ "xpack.ml.newJob.recognize.advancedLabel": "高级", "xpack.ml.newJob.recognize.advancedSettingsAriaLabel": "高级设置", "xpack.ml.newJob.recognize.alreadyExistsLabel": "(已存在)", - "xpack.ml.newJob.recognize.analysisRunningLabel": "分析正在运行", "xpack.ml.newJob.recognize.cancelJobOverrideLabel": "关闭", "xpack.ml.newJob.recognize.createJobButtonAriaLabel": "创建作业", "xpack.ml.newJob.recognize.createJobButtonLabel": "创建{numberOfJobs, plural, zero {作业} one {Job} other {Jobs}}", 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 713d0cb85e2e82..525c3781be7495 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 54f9fe9d298f27..6d8afee1ff9504 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, { diff --git a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts index a74b55c24e227e..970d9ad166982a 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts @@ -8,6 +8,7 @@ import { UMElasticsearchQueryFn } from '../adapters'; import { getFilterClause } from '../helper'; import { HistogramResult, HistogramQueryResult } from '../../../common/runtime_types'; import { QUERY } from '../../../common/constants'; +import { getHistogramInterval } from '../helper/get_histogram_interval'; export interface GetPingHistogramParams { /** @member dateRangeStart timestamp bounds */ @@ -36,22 +37,6 @@ export const getPingHistogram: UMElasticsearchQueryFn< } const filter = getFilterClause(from, to, additionalFilters); - const seriesHistogram: any = {}; - - if (bucketSize) { - seriesHistogram.date_histogram = { - field: '@timestamp', - fixed_interval: bucketSize, - missing: 0, - }; - } else { - seriesHistogram.auto_date_histogram = { - field: '@timestamp', - buckets: QUERY.DEFAULT_BUCKET_COUNT, - missing: 0, - }; - } - const params = { index: dynamicSettings.heartbeatIndices, body: { @@ -63,7 +48,12 @@ export const getPingHistogram: UMElasticsearchQueryFn< size: 0, aggs: { timeseries: { - ...seriesHistogram, + date_histogram: { + field: '@timestamp', + fixed_interval: + bucketSize || getHistogramInterval(from, to, QUERY.DEFAULT_BUCKET_COUNT) + 'ms', + missing: 0, + }, aggs: { down: { filter: { diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts index a48460d7a3b232..6a827298521dda 100644 --- a/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts @@ -16,7 +16,7 @@ export default function ({ getService }: FtrProviderContext) { const { createPipeline, deletePipeline } = registerEsHelpers(getService); - describe('Pipelines', function () { + describe.skip('Pipelines', function () { describe('Create', () => { const PIPELINE_ID = 'test_create_pipeline'; const REQUIRED_FIELDS_PIPELINE_ID = 'test_create_required_fields_pipeline'; diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram.json index 562ba64c24b0b9..85ce545ed92b0e 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram.json @@ -1,121 +1,157 @@ { "histogram": [ { - "x": 1568172664000, + "x": 1568172657286, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172694000, + "x": 1568172680087, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172724000, + "x": 1568172702888, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172754000, + "x": 1568172725689, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172748490, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172784000, + "x": 1568172771291, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172814000, + "x": 1568172794092, "downCount": 8, "upCount": 92, "y": 1 }, { - "x": 1568172844000, + "x": 1568172816893, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172839694, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172874000, + "x": 1568172862495, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172904000, + "x": 1568172885296, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172934000, + "x": 1568172908097, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172930898, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172964000, + "x": 1568172953699, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172994000, + "x": 1568172976500, "downCount": 8, "upCount": 92, "y": 1 }, { - "x": 1568173024000, + "x": 1568172999301, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173022102, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568173054000, + "x": 1568173044903, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568173084000, + "x": 1568173067704, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568173114000, + "x": 1568173090505, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173113306, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568173144000, + "x": 1568173136107, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568173174000, + "x": 1568173158908, "downCount": 8, "upCount": 92, "y": 1 }, { - "x": 1568173204000, + "x": 1568173181709, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568173234000, + "x": 1568173204510, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173227311, "downCount": 7, "upCount": 93, "y": 1 diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_filter.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_filter.json index 42be715c4acd44..fe5dc9dd3da3f2 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_filter.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_filter.json @@ -1,121 +1,157 @@ { "histogram": [ { - "x": 1568172664000, + "x": 1568172657286, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172694000, + "x": 1568172680087, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172724000, + "x": 1568172702888, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172754000, + "x": 1568172725689, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172748490, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172784000, + "x": 1568172771291, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172814000, + "x": 1568172794092, "downCount": 0, "upCount": 92, "y": 1 }, { - "x": 1568172844000, + "x": 1568172816893, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172839694, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172874000, + "x": 1568172862495, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172904000, + "x": 1568172885296, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172934000, + "x": 1568172908097, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172930898, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172964000, + "x": 1568172953699, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172994000, + "x": 1568172976500, "downCount": 0, "upCount": 92, "y": 1 }, { - "x": 1568173024000, + "x": 1568172999301, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173022102, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568173054000, + "x": 1568173044903, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568173084000, + "x": 1568173067704, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568173114000, + "x": 1568173090505, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173113306, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568173144000, + "x": 1568173136107, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568173174000, + "x": 1568173158908, "downCount": 0, "upCount": 92, "y": 1 }, { - "x": 1568173204000, + "x": 1568173181709, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568173234000, + "x": 1568173204510, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173227311, "downCount": 0, "upCount": 93, "y": 1 diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_id.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_id.json index 9a726db6163253..e54738cf5dbd73 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_id.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_id.json @@ -1,121 +1,157 @@ { "histogram": [ { - "x": 1568172664000, + "x": 1568172657286, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172694000, + "x": 1568172680087, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172724000, + "x": 1568172702888, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172754000, + "x": 1568172725689, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172748490, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172784000, + "x": 1568172771291, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172814000, + "x": 1568172794092, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172844000, + "x": 1568172816893, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172839694, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172874000, + "x": 1568172862495, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172904000, + "x": 1568172885296, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172934000, + "x": 1568172908097, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172930898, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172964000, + "x": 1568172953699, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172994000, + "x": 1568172976500, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568173024000, + "x": 1568172999301, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173022102, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568173054000, + "x": 1568173044903, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568173084000, + "x": 1568173067704, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568173114000, + "x": 1568173090505, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173113306, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568173144000, + "x": 1568173136107, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568173174000, + "x": 1568173158908, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568173204000, + "x": 1568173181709, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568173234000, + "x": 1568173204510, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173227311, "downCount": 0, "upCount": 1, "y": 1 diff --git a/x-pack/test/api_integration/apis/uptime/rest/ping_histogram.ts b/x-pack/test/api_integration/apis/uptime/rest/ping_histogram.ts index ffcb1a829f0f8b..b2504e3b921f75 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/ping_histogram.ts +++ b/x-pack/test/api_integration/apis/uptime/rest/ping_histogram.ts @@ -6,7 +6,6 @@ import { expectFixtureEql } from './helper/expect_fixture_eql'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { assertCloseTo } from '../../../../../plugins/uptime/server/lib/helper'; export default function ({ getService }: FtrProviderContext) { describe('pingHistogram', () => { @@ -21,10 +20,6 @@ export default function ({ getService }: FtrProviderContext) { ); const data = apiResponse.body; - // manually testing this value and then removing it to avoid flakiness - const { interval } = data; - assertCloseTo(interval, 22801, 100); - delete data.interval; expectFixtureEql(data, 'ping_histogram'); }); @@ -38,9 +33,6 @@ export default function ({ getService }: FtrProviderContext) { ); const data = apiResponse.body; - const { interval } = data; - assertCloseTo(interval, 22801, 100); - delete data.interval; expectFixtureEql(data, 'ping_histogram_by_id'); }); @@ -55,9 +47,6 @@ export default function ({ getService }: FtrProviderContext) { ); const data = apiResponse.body; - const { interval } = data; - assertCloseTo(interval, 22801, 100); - delete data.interval; expectFixtureEql(data, 'ping_histogram_by_filter'); }); }); diff --git a/x-pack/test/functional/apps/graph/graph.ts b/x-pack/test/functional/apps/graph/graph.ts index 803e5e8f80d707..c2500dca784447 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/apps/upgrade_assistant/upgrade_assistant.ts b/x-pack/test/functional/apps/upgrade_assistant/upgrade_assistant.ts index 85ad98727cea50..57b8fb23613bea 100644 --- a/x-pack/test/functional/apps/upgrade_assistant/upgrade_assistant.ts +++ b/x-pack/test/functional/apps/upgrade_assistant/upgrade_assistant.ts @@ -11,14 +11,22 @@ export default function upgradeAssistantFunctionalTests({ getPageObjects, }: FtrProviderContext) { const esArchiver = getService('esArchiver'); - const PageObjects = getPageObjects(['upgradeAssistant']); + const PageObjects = getPageObjects(['upgradeAssistant', 'common']); + const security = getService('security'); + const log = getService('log'); describe('Upgrade Checkup', function () { this.tags('includeFirefox'); - before(async () => await esArchiver.load('empty_kibana')); + + before(async () => { + await esArchiver.load('empty_kibana'); + await security.testUser.setRoles(['global_upgrade_assistant_role']); + }); + after(async () => { await PageObjects.upgradeAssistant.expectTelemetryHasFinish(); await esArchiver.unload('empty_kibana'); + await security.testUser.restoreDefaults(); }); it('allows user to navigate to upgrade checkup', async () => { @@ -28,9 +36,17 @@ export default function upgradeAssistantFunctionalTests({ it('allows user to toggle deprecation logging', async () => { await PageObjects.upgradeAssistant.navigateToPage(); + log.debug('expect initial state to be ON'); await PageObjects.upgradeAssistant.expectDeprecationLoggingLabel('On'); + log.debug('Now toggle to off'); await PageObjects.upgradeAssistant.toggleDeprecationLogging(); + await PageObjects.common.sleep(2000); + log.debug('expect state to be OFF after toggle'); await PageObjects.upgradeAssistant.expectDeprecationLoggingLabel('Off'); + await PageObjects.upgradeAssistant.toggleDeprecationLogging(); + await PageObjects.common.sleep(2000); + log.debug('expect state to be ON after toggle'); + await PageObjects.upgradeAssistant.expectDeprecationLoggingLabel('On'); }); it('allows user to open cluster tab', async () => { diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 5c13e430ae2ca6..fdd694e73394e3 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -284,6 +284,20 @@ export default async function ({ readConfigFile }) { ], }, + global_upgrade_assistant_role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: [ + { + feature: { + discover: ['read'], + }, + spaces: ['*'], + }, + ], + }, + global_ccr_role: { elasticsearch: { cluster: ['manage', 'manage_ccr'], diff --git a/x-pack/test/functional/page_objects/graph_page.ts b/x-pack/test/functional/page_objects/graph_page.ts index 0d3e2c10579f54..fe049327fe38b0 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'); diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts index 57321ab4cd911f..0c5e15ed4104ca 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts @@ -78,7 +78,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { 'Protect East Coastrev. 1', 'elastic', 'elastic', - `${policyInfo.packageConfig.package?.title} v${policyInfo.packageConfig.package?.version}`, + `v${policyInfo.packageConfig.package?.version}`, '', ]); [policyRow[2], policyRow[4]].forEach((relativeDate) => { @@ -131,6 +131,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(endpointConfig).not.to.be(undefined); }); + it('should have empty value for package configuration name', async () => { + await pageObjects.ingestManagerCreatePackageConfig.selectAgentConfig(); + expect(await pageObjects.ingestManagerCreatePackageConfig.getPackageConfigName()).to.be(''); + }); + it('should redirect user back to Policy List after a successful save', async () => { const newPolicyName = `endpoint policy ${Date.now()}`; await pageObjects.ingestManagerCreatePackageConfig.selectAgentConfig(); diff --git a/x-pack/test/security_solution_endpoint/page_objects/ingest_manager_create_package_config_page.ts b/x-pack/test/security_solution_endpoint/page_objects/ingest_manager_create_package_config_page.ts index dd3fc637a3d6c7..dfdb528b7362cd 100644 --- a/x-pack/test/security_solution_endpoint/page_objects/ingest_manager_create_package_config_page.ts +++ b/x-pack/test/security_solution_endpoint/page_objects/ingest_manager_create_package_config_page.ts @@ -62,6 +62,13 @@ export function IngestManagerCreatePackageConfig({ } }, + /** + * Returns the package config name currently populated on the input field + */ + async getPackageConfigName() { + return testSubjects.getAttribute('packageConfigNameInput', 'value'); + }, + /** * Set the name of the package config on the input field * @param name