From c5daa370c725641a7f9cc737f6f309b741ea206e Mon Sep 17 00:00:00 2001 From: Kurt Date: Tue, 21 Nov 2023 09:21:37 -0500 Subject: [PATCH 01/28] Upgrade chromedriver (#171545) ## Summary Upgrade `chromedriver` ## Changelog https://github.com/giggio/node-chromedriver/compare/117.0.3...119.0.1 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 76 ++++++++++++++++++++++++++-------------------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index 0f8260566b536f..b2ef6e211107c1 100644 --- a/package.json +++ b/package.json @@ -1469,7 +1469,7 @@ "blob-polyfill": "^7.0.20220408", "callsites": "^3.1.0", "chance": "1.0.18", - "chromedriver": "^119.0.0", + "chromedriver": "^119.0.1", "clean-webpack-plugin": "^3.0.0", "cli-table3": "^0.6.1", "copy-webpack-plugin": "^6.0.2", diff --git a/yarn.lock b/yarn.lock index 8016568cd10f5a..f9b51eea5202c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8386,10 +8386,10 @@ "@tanstack/query-core" "4.29.11" use-sync-external-store "^1.2.0" -"@testim/chrome-version@^1.1.3": - version "1.1.3" - resolved "https://registry.yarnpkg.com/@testim/chrome-version/-/chrome-version-1.1.3.tgz#fbb68696899d7b8c1b9b891eded9c04fe2cd5529" - integrity sha512-g697J3WxV/Zytemz8aTuKjTGYtta9+02kva3C1xc7KXB8GdbfE1akGJIsZLyY/FSh2QrnE+fiB7vmWU3XNcb6A== +"@testim/chrome-version@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@testim/chrome-version/-/chrome-version-1.1.4.tgz#86e04e677cd6c05fa230dd15ac223fa72d1d7090" + integrity sha512-kIhULpw9TrGYnHp/8VfdcneIcxKnLixmADtukQRtJUmsVlMg0niMkwV0xZmi8hqa57xqilIHjWFA0GKvEjVU5g== "@testing-library/dom@^8.0.0": version "8.19.0" @@ -11574,7 +11574,7 @@ axios@^0.26.0: dependencies: follow-redirects "^1.14.8" -axios@^1.3.4, axios@^1.4.0, axios@^1.6.0: +axios@^1.3.4, axios@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.0.tgz#f1e5292f26b2fd5c2e66876adc5b06cdbd7d2102" integrity sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg== @@ -12846,18 +12846,18 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -chromedriver@^119.0.0: - version "119.0.0" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-119.0.0.tgz#f250c442e72266f3e28d2b1eebc6a671a8629340" - integrity sha512-3TmabGT7xg57/Jbsg6B/Kqk3HaSbCP1ZHkR5zNft5vT/IWKjZCAGTH9waMI+i5KHSEiMH0zOw/WF98l+1Npkpw== +chromedriver@^119.0.1: + version "119.0.1" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-119.0.1.tgz#064f3650790ccea055e9bfd95c600f5ea60295e9" + integrity sha512-lpCFFLaXPpvElTaUOWKdP74pFb/sJhWtWqMjn7Ju1YriWn8dT5JBk84BGXMPvZQs70WfCYWecxdMmwfIu1Mupg== dependencies: - "@testim/chrome-version" "^1.1.3" - axios "^1.4.0" - compare-versions "^6.0.0" + "@testim/chrome-version" "^1.1.4" + axios "^1.6.0" + compare-versions "^6.1.0" extract-zip "^2.0.1" https-proxy-agent "^5.0.1" proxy-from-env "^1.1.0" - tcp-port-used "^1.0.1" + tcp-port-used "^1.0.2" chromium-bidi@0.4.28: version "0.4.28" @@ -13266,7 +13266,7 @@ compare-versions@3.5.1: resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.5.1.tgz#26e1f5cf0d48a77eced5046b9f67b6b61075a393" integrity sha512-9fGPIB7C6AyM18CJJBHt5EnCZDG3oiTJYy0NjfIAGjKpzv0tkxWko7TNQHF5ymqm7IH03tqmeuBxtvD+Izh6mg== -compare-versions@^6.0.0: +compare-versions@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-6.1.0.tgz#3f2131e3ae93577df111dba133e6db876ffe127a" integrity sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg== @@ -14367,13 +14367,6 @@ debug@4, debug@4.3.4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, de dependencies: ms "2.1.2" -debug@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.0.tgz#373687bffa678b38b1cd91f861b63850035ddc87" - integrity sha512-heNPJUJIqC+xB6ayLAMHaIrmN9HKa7aQO8MGqKpvCA+uJYVcvR6l5kgdrhRuwPFHU7P5/A1w0BjByPHwpfTDKg== - dependencies: - ms "^2.1.1" - debug@4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" @@ -14381,6 +14374,13 @@ debug@4.1.1: dependencies: ms "^2.1.1" +debug@4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== + dependencies: + ms "2.1.2" + debuglog@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" @@ -18854,10 +18854,10 @@ io-ts@^2.0.5: resolved "https://registry.yarnpkg.com/io-ts/-/io-ts-2.0.5.tgz#e6e3db9df8b047f9cbd6b69e7d2ad3e6437a0b13" integrity sha512-pL7uUptryanI5Glv+GUv7xh+aLBjxGEDmLwmEYNSx0yOD3djK0Nw5Bt0N6BAkv9LadOUU7QKpRsLcqnTh3UlLA== -ip-regex@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" - integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= +ip-regex@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-4.3.0.tgz#687275ab0f57fa76978ff8f4dddc8a23d5990db5" + integrity sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q== ip@^1.1.8: version "1.1.8" @@ -19417,7 +19417,7 @@ is-url-superb@^4.0.0: resolved "https://registry.yarnpkg.com/is-url-superb/-/is-url-superb-4.0.0.tgz#b54d1d2499bb16792748ac967aa3ecb41a33a8c2" integrity sha512-GI+WjezhPPcbM+tqE9LnmsY5qqjwHzTvjJ36wxYX5ujNXefSUJ/T17r5bqDV8yLhcgB59KTPNOc9O9cmHTPWsA== -is-url@^1.2.2, is-url@^1.2.4: +is-url@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/is-url/-/is-url-1.2.4.tgz#04a4df46d28c4cff3d73d01ff06abeb318a1aa52" integrity sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww== @@ -19491,14 +19491,14 @@ is-yarn-global@^0.3.0: resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== -is2@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is2/-/is2-2.0.1.tgz#8ac355644840921ce435d94f05d3a94634d3481a" - integrity sha512-+WaJvnaA7aJySz2q/8sLjMb2Mw14KTplHmSwcSpZ/fWJPkUmqw3YTzSWbPJ7OAwRvdYTWF2Wg+yYJ1AdP5Z8CA== +is2@^2.0.6: + version "2.0.9" + resolved "https://registry.yarnpkg.com/is2/-/is2-2.0.9.tgz#ff63b441f90de343fa8fac2125ee170da8e8240d" + integrity sha512-rZkHeBn9Zzq52sd9IUIV3a5mfwBY+o2HePMh0wkGBM4z4qjvy2GwVxQ6nNXSfw6MmVP6gf1QIlWjiOavhM3x5g== dependencies: deep-is "^0.1.3" - ip-regex "^2.1.0" - is-url "^1.2.2" + ip-regex "^4.1.0" + is-url "^1.2.4" isarray@0.0.1: version "0.0.1" @@ -28592,13 +28592,13 @@ tcomb@^3.0.0, tcomb@^3.2.17: resolved "https://registry.yarnpkg.com/tcomb/-/tcomb-3.2.29.tgz#32404fe9456d90c2cf4798682d37439f1ccc386c" integrity sha512-di2Hd1DB2Zfw6StGv861JoAF5h/uQVu/QJp2g8KVbtfKnoHdBQl5M32YWq6mnSYBQ1vFFrns5B1haWJL7rKaOQ== -tcp-port-used@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/tcp-port-used/-/tcp-port-used-1.0.1.tgz#46061078e2d38c73979a2c2c12b5a674e6689d70" - integrity sha512-rwi5xJeU6utXoEIiMvVBMc9eJ2/ofzB+7nLOdnZuFTmNCLqRiQh2sMG9MqCxHU/69VC/Fwp5dV9306Qd54ll1Q== +tcp-port-used@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/tcp-port-used/-/tcp-port-used-1.0.2.tgz#9652b7436eb1f4cfae111c79b558a25769f6faea" + integrity sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA== dependencies: - debug "4.1.0" - is2 "2.0.1" + debug "4.3.1" + is2 "^2.0.6" teex@^1.0.1: version "1.0.1" From 540f2b632eab0d19cbeb2afe823caf3ec395745a Mon Sep 17 00:00:00 2001 From: Pete Hampton Date: Tue, 21 Nov 2023 14:24:47 +0000 Subject: [PATCH 02/28] Remove Kibana Prometheus Exporter from documentation. (#171624) ## Summary RE: https://github.com/pjhampton/kibana-prometheus-exporter/issues/344 I am sunsetting development on a community Kibana plugin I maintain. This PR removes it from the official documentation. --- docs/user/plugins.asciidoc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/user/plugins.asciidoc b/docs/user/plugins.asciidoc index 0bf407ac1ff900..8dc3d8b6e9e0d5 100644 --- a/docs/user/plugins.asciidoc +++ b/docs/user/plugins.asciidoc @@ -81,7 +81,6 @@ Use it to create, edit and embed visualizations, and also to search inside an em * https://github.com/sw-jung/kibana_markdown_doc_view[Markdown Doc View] (sw-jung) - A plugin for custom doc view using markdown+handlebars template. * https://github.com/datasweet-fr/kibana-datasweet-formula[Datasweet Formula] (datasweet) - enables calculated metric on any standard Kibana visualization. -* https://github.com/pjhampton/kibana-prometheus-exporter[Prometheus Exporter] - exports the Kibana metrics in the prometheus format NOTE: To add your plugin to this page, open a {kib-repo}tree/{branch}/docs/plugins/known-plugins.asciidoc[pull request]. @@ -183,4 +182,4 @@ you must specify the path to that configuration file each time you use the `bin/ 0:: Success 64:: Unknown command or incorrect option parameter 74:: I/O error -70:: Other error +70:: Other error \ No newline at end of file From d8ef2d0fb16354492bdeff74ad4e581c7fc1cfa1 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Tue, 21 Nov 2023 07:42:41 -0700 Subject: [PATCH 03/28] Moves SOR bulkCreate unit tests to dedicated file (#171431) --- .../src/lib/apis/bulk_create.test.ts | 1048 +++++++++++++++++ .../src/lib/repository.test.ts | 916 -------------- 2 files changed, 1048 insertions(+), 916 deletions(-) create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.test.ts diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.test.ts new file mode 100644 index 00000000000000..3faeee08048ef4 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.test.ts @@ -0,0 +1,1048 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* eslint-disable @typescript-eslint/no-shadow */ + +import { + pointInTimeFinderMock, + mockGetBulkOperationError, + mockGetCurrentTime, + mockPreflightCheckForCreate, + mockGetSearchDsl, +} from '../repository.test.mock'; + +import type { Payload } from '@hapi/boom'; + +import type { SavedObjectsBulkCreateObject } from '@kbn/core-saved-objects-api-server'; +import { + type SavedObjectsRawDoc, + type SavedObjectUnsanitizedDoc, + type SavedObjectReference, +} from '@kbn/core-saved-objects-server'; +import { ALL_NAMESPACES_STRING } from '@kbn/core-saved-objects-utils-server'; +import { SavedObjectsRepository } from '../repository'; +import { loggerMock } from '@kbn/logging-mocks'; +import { SavedObjectsSerializer } from '@kbn/core-saved-objects-base-server-internal'; +import { kibanaMigratorMock } from '../../mocks'; +import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; + +import { + CUSTOM_INDEX_TYPE, + NAMESPACE_AGNOSTIC_TYPE, + MULTI_NAMESPACE_TYPE, + MULTI_NAMESPACE_ISOLATED_TYPE, + HIDDEN_TYPE, + mockVersionProps, + mockTimestampFields, + mockTimestamp, + mappings, + mockVersion, + createRegistry, + createDocumentMigrator, + createSpySerializer, + bulkCreateSuccess, + getMockBulkCreateResponse, + expectErrorResult, + expectErrorInvalidType, + expectErrorConflict, + expectError, + createBadRequestErrorPayload, + expectCreateResult, + mockTimestampFieldsWithCreated, +} from '../../test_helpers/repository.test.common'; + +// BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository +// so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. + +interface ExpectedErrorResult { + type: string; + id: string; + error: Record; +} + +describe('SavedObjectsRepository', () => { + let client: ReturnType; + let repository: SavedObjectsRepository; + let migrator: ReturnType; + let logger: ReturnType; + let serializer: jest.Mocked; + + const registry = createRegistry(); + const documentMigrator = createDocumentMigrator(registry); + + const expectSuccess = ({ type, id }: { type: string; id: string }) => { + // @ts-expect-error TS is not aware of the extension + return expect.toBeDocumentWithoutError(type, id); + }; + + const expectMigrationArgs = (args: unknown, contains = true, n = 1) => { + const obj = contains ? expect.objectContaining(args) : expect.not.objectContaining(args); + expect(migrator.migrateDocument).toHaveBeenNthCalledWith( + n, + obj, + expect.objectContaining({ + allowDowngrade: expect.any(Boolean), + }) + ); + }; + + beforeEach(() => { + pointInTimeFinderMock.mockClear(); + client = elasticsearchClientMock.createElasticsearchClient(); + migrator = kibanaMigratorMock.create(); + documentMigrator.prepareMigrations(); + migrator.migrateDocument = jest.fn().mockImplementation(documentMigrator.migrate); + migrator.runMigrations = jest.fn().mockResolvedValue([{ status: 'skipped' }]); + logger = loggerMock.create(); + + // create a mock serializer "shim" so we can track function calls, but use the real serializer's implementation + serializer = createSpySerializer(registry); + + const allTypes = registry.getAllTypes().map((type) => type.name); + const allowedTypes = [...new Set(allTypes.filter((type) => !registry.isHidden(type)))]; + + // @ts-expect-error must use the private constructor to use the mocked serializer + repository = new SavedObjectsRepository({ + index: '.kibana-test', + mappings, + client, + migrator, + typeRegistry: registry, + serializer, + allowedTypes, + logger, + }); + + mockGetCurrentTime.mockReturnValue(mockTimestamp); + mockGetSearchDsl.mockClear(); + }); + + // Setup migration mock for creating an object + const mockMigrationVersion = { foo: '2.3.4' }; + const mockMigrateDocument = (doc: SavedObjectUnsanitizedDoc) => ({ + ...doc, + attributes: { + ...doc.attributes, + ...(doc.attributes?.title && { title: `${doc.attributes.title}!!` }), + }, + migrationVersion: mockMigrationVersion, + managed: doc.managed ?? false, + references: [{ name: 'search_0', type: 'search', id: '123' }], + }); + + describe('#bulkCreate', () => { + beforeEach(() => { + mockPreflightCheckForCreate.mockReset(); + mockPreflightCheckForCreate.mockImplementation(({ objects }) => { + return Promise.resolve(objects.map(({ type, id }) => ({ type, id }))); // respond with no errors by default + }); + }); + + const obj1 = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + managed: false, + }; + const obj2 = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + managed: false, + }; + const namespace = 'foo-namespace'; + + // bulk create calls have two objects for each source -- the action, and the source + const expectClientCallArgsAction = ( + objects: Array<{ type: string; id?: string; if_primary_term?: string; if_seq_no?: string }>, + { + method, + _index = expect.any(String), + getId = () => expect.any(String), + }: { method: string; _index?: string; getId?: (type: string, id?: string) => string } + ) => { + const body = []; + for (const { type, id, if_primary_term: ifPrimaryTerm, if_seq_no: ifSeqNo } of objects) { + body.push({ + [method]: { + _index, + _id: getId(type, id), + ...(ifPrimaryTerm && ifSeqNo + ? { if_primary_term: expect.any(Number), if_seq_no: expect.any(Number) } + : {}), + }, + }); + body.push(expect.any(Object)); + } + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }; + + const expectObjArgs = ( + { + type, + attributes, + references, + }: { type: string; attributes: unknown; references?: SavedObjectReference[] }, + overrides: Record = {} + ) => [ + expect.any(Object), + expect.objectContaining({ + [type]: attributes, + references, + type, + ...overrides, + ...mockTimestampFields, + }), + ]; + describe('client calls', () => { + it(`should use the ES bulk action by default`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expect(client.bulk).toHaveBeenCalledTimes(1); + }); + + it(`should use the preflightCheckForCreate action before bulk action for any types that are multi-namespace, when id is defined`, async () => { + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; + await bulkCreateSuccess(client, repository, objects); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(mockPreflightCheckForCreate).toHaveBeenCalledTimes(1); + expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( + expect.objectContaining({ + objects: [ + { + type: MULTI_NAMESPACE_ISOLATED_TYPE, + id: obj2.id, + overwrite: false, + namespaces: ['default'], + }, + ], + }) + ); + }); + + 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(client, repository, objects, { overwrite: true }); + 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(client, repository, objects); + expectClientCallArgsAction(objects, { method: 'create' }); + }); + + it(`should use the ES index method if ID is defined and overwrite=true`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { overwrite: true }); + expectClientCallArgsAction([obj1, obj2], { method: 'index' }); + }); + + it(`should use the ES index method with version if ID and version are defined and overwrite=true`, async () => { + await bulkCreateSuccess( + client, + repository, + [ + { + ...obj1, + version: mockVersion, + }, + obj2, + ], + { overwrite: true } + ); + + const obj1WithSeq = { + ...obj1, + managed: obj1.managed, + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + + expectClientCallArgsAction([obj1WithSeq, obj2], { method: 'index' }); + }); + + it(`should use the ES create method if ID is defined and overwrite=false`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expectClientCallArgsAction([obj1, obj2], { method: 'create' }); + }); + + it(`should use the ES index method if ID is defined, overwrite=true and managed=true in a document`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { + overwrite: true, + managed: true, + }); + expectClientCallArgsAction([obj1, obj2], { method: 'index' }); + }); + + it(`should use the ES create method if ID is defined, overwrite=false and managed=true in a document`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { managed: true }); + expectClientCallArgsAction([obj1, obj2], { method: 'create' }); + }); + + it(`formats the ES request`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + // this test only ensures that the client accepts the managed field in a document + it(`formats the ES request with managed=true in a document`, async () => { + const obj1WithManagedTrue = { ...obj1, managed: true }; + const obj2WithManagedTrue = { ...obj2, managed: true }; + await bulkCreateSuccess(client, repository, [obj1WithManagedTrue, obj2WithManagedTrue]); + const body = [...expectObjArgs(obj1WithManagedTrue), ...expectObjArgs(obj2WithManagedTrue)]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + describe('originId', () => { + it(`returns error if originId is set for non-multi-namespace type`, async () => { + const result = await repository.bulkCreate([ + { ...obj1, originId: 'some-originId' }, + { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE, originId: 'some-originId' }, + ]); + expect(result.saved_objects).toEqual([ + expect.objectContaining({ id: obj1.id, type: obj1.type, error: expect.anything() }), + expect.objectContaining({ + id: obj2.id, + type: NAMESPACE_AGNOSTIC_TYPE, + error: expect.anything(), + }), + ]); + expect(client.bulk).not.toHaveBeenCalled(); + }); + + it(`defaults to no originId`, async () => { + const objects = [ + { ...obj1, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + ]; + + await bulkCreateSuccess(client, repository, objects); + const expected = expect.not.objectContaining({ originId: expect.anything() }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + describe('with existing originId', () => { + beforeEach(() => { + mockPreflightCheckForCreate.mockImplementation(({ objects }) => { + const existingDocument = { + _source: { originId: 'existing-originId' }, + } as SavedObjectsRawDoc; + return Promise.resolve( + objects.map(({ type, id }) => ({ type, id, existingDocument })) + ); + }); + }); + + it(`accepts custom originId for multi-namespace type`, async () => { + // The preflight result has `existing-originId`, but that is discarded + const objects = [ + { ...obj1, type: MULTI_NAMESPACE_TYPE, originId: 'some-originId' }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, originId: 'some-originId' }, + ]; + await bulkCreateSuccess(client, repository, objects); + const expected = expect.objectContaining({ originId: 'some-originId' }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + it(`accepts undefined originId`, async () => { + // The preflight result has `existing-originId`, but that is discarded + const objects = [ + { ...obj1, type: MULTI_NAMESPACE_TYPE, originId: undefined }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, originId: undefined }, + ]; + await bulkCreateSuccess(client, repository, objects); + const expected = expect.not.objectContaining({ originId: expect.anything() }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + it(`preserves existing originId if originId option is not set`, async () => { + const objects = [ + { ...obj1, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + ]; + await bulkCreateSuccess(client, repository, objects); + const expected = expect.objectContaining({ originId: 'existing-originId' }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + 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(client, repository, [obj1, obj2], { namespace }); + const expected = expect.objectContaining({ namespace }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + // this only ensures we don't override any other options + it(`adds managed=false to request body if declared for any types that are single-namespace`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace, managed: false }); + const expected = expect.objectContaining({ namespace, managed: false }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + // this only ensures we don't override any other options + it(`adds managed=true to request body if declared for any types that are single-namespace`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace, managed: true }); + const expected = expect.objectContaining({ namespace, managed: true }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + it(`normalizes options.namespace from 'default' to undefined`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace: 'default' }); + const expected = expect.not.objectContaining({ namespace: 'default' }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + 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 () => { + const objects = [ + { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + ]; + await bulkCreateSuccess(client, repository, objects, { namespace }); + const expected = expect.not.objectContaining({ namespace: expect.anything() }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + it(`adds namespaces to request body for any types that are multi-namespace`, async () => { + const test = async (namespace?: string) => { + const objects = [obj1, obj2].map((x) => ({ ...x, type: MULTI_NAMESPACE_ISOLATED_TYPE })); + const [o1, o2] = objects; + mockPreflightCheckForCreate.mockResolvedValueOnce([ + { type: o1.type, id: o1.id! }, // first object does not have an existing document to overwrite + { + type: o2.type, + id: o2.id!, + existingDocument: { _id: o2.id!, _source: { namespaces: ['*'], type: o2.type } }, // second object does have an existing document to overwrite + }, + ]); + await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); + const expected1 = expect.objectContaining({ namespaces: [namespace ?? 'default'] }); + const expected2 = expect.objectContaining({ namespaces: ['*'] }); + const body = [expect.any(Object), expected1, expect.any(Object), expected2]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + mockPreflightCheckForCreate.mockReset(); + }; + await test(undefined); + await test(namespace); + }); + + it(`adds initialNamespaces instead of namespace`, async () => { + const test = async (namespace?: string) => { + const ns2 = 'bar-namespace'; + const ns3 = 'baz-namespace'; + const objects = [ + { ...obj1, type: 'dashboard', initialNamespaces: [ns2] }, + { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE, initialNamespaces: [ns2] }, + { ...obj1, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns2, ns3] }, + ]; + const [o1, o2, o3] = objects; + mockPreflightCheckForCreate.mockResolvedValueOnce([ + // first object does not get passed in to preflightCheckForCreate at all + { type: o2.type, id: o2.id! }, // second object does not have an existing document to overwrite + { + type: o3.type, + id: o3.id!, + existingDocument: { + _id: o3.id!, + _source: { type: o3.type, namespaces: [namespace ?? 'default', 'something-else'] }, // third object does have an existing document to overwrite + }, + }, + ]); + await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); + const body = [ + { index: expect.objectContaining({ _id: `${ns2}:dashboard:${o1.id}` }) }, + expect.objectContaining({ namespace: ns2 }), + { + index: expect.objectContaining({ + _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${o2.id}`, + }), + }, + expect.objectContaining({ namespaces: [ns2] }), + { index: expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${o3.id}` }) }, + expect.objectContaining({ namespaces: [ns2, ns3] }), + ]; + expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( + expect.objectContaining({ + objects: [ + // assert that the initialNamespaces fields were passed into preflightCheckForCreate instead of the current namespace + { type: o2.type, id: o2.id, overwrite: true, namespaces: o2.initialNamespaces }, + { type: o3.type, id: o3.id, overwrite: true, namespaces: o3.initialNamespaces }, + ], + }) + ); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + mockPreflightCheckForCreate.mockReset(); + }; + await test(undefined); + await test(namespace); + }); + + it(`normalizes initialNamespaces from 'default' to undefined`, async () => { + const test = async (namespace?: string) => { + const objects = [{ ...obj1, type: 'dashboard', initialNamespaces: ['default'] }]; + await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); + const body = [ + { index: expect.objectContaining({ _id: `dashboard:${obj1.id}` }) }, + expect.not.objectContaining({ namespace: 'default' }), + ]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + }; + await test(undefined); + await test(namespace); + }); + + it(`doesn't add namespaces to request body for any types that are not multi-namespace`, async () => { + const test = async (namespace?: string) => { + const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; + await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); + const expected = expect.not.objectContaining({ namespaces: expect.anything() }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + }; + await test(undefined); + await test(namespace); + }); + + it(`defaults to a refresh setting of wait_for`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ refresh: 'wait_for' }), + expect.anything() + ); + }); + + it(`should use default index`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expectClientCallArgsAction([obj1, obj2], { + method: 'create', + _index: '.kibana-test_8.0.0-testing', + }); + }); + + it(`should use custom index`, async () => { + await bulkCreateSuccess( + client, + repository, + [obj1, obj2].map((x) => ({ ...x, type: CUSTOM_INDEX_TYPE })) + ); + expectClientCallArgsAction([obj1, obj2], { + method: 'create', + _index: 'custom_8.0.0-testing', + }); + }); + + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + const getId = (type: string, id: string = '') => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix) + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); + 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: string, id: string = '') => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); + }); + + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + const getId = (type: string, id: string = '') => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) + const objects = [ + { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + ]; + await bulkCreateSuccess(client, repository, objects, { namespace }); + expectClientCallArgsAction(objects, { method: 'create', getId }); + }); + }); + + describe('errors', () => { + afterEach(() => { + mockGetBulkOperationError.mockReset(); + }); + + const obj3 = { + type: 'dashboard', + id: 'three', + attributes: { title: 'Test Three' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + + const bulkCreateError = async ( + obj: SavedObjectsBulkCreateObject, + isBulkError: boolean | undefined, + expectedErrorResult: ExpectedErrorResult + ) => { + let response; + if (isBulkError) { + // mock the bulk error for only the second object + mockGetBulkOperationError.mockReturnValueOnce(undefined); + mockGetBulkOperationError.mockReturnValueOnce(expectedErrorResult.error as Payload); + response = getMockBulkCreateResponse([obj1, obj, obj2]); + } else { + response = getMockBulkCreateResponse([obj1, obj2]); + } + client.bulk.mockResponseOnce(response); + + const objects = [obj1, obj, obj2]; + const result = await repository.bulkCreate(objects); + expect(client.bulk).toHaveBeenCalled(); + const objCall = isBulkError ? expectObjArgs(obj) : []; + const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + expect(result).toEqual({ + saved_objects: [expectSuccess(obj1), expectedErrorResult, expectSuccess(obj2)], + }); + }; + + it(`throws when options.namespace is '*'`, async () => { + await expect( + repository.bulkCreate([obj3], { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestErrorPayload('"options.namespace" cannot be "*"')); + }); + + it(`returns error when initialNamespaces is used with a space-agnostic object`, async () => { + const obj = { ...obj3, type: NAMESPACE_AGNOSTIC_TYPE, initialNamespaces: [] }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( + obj, + createBadRequestErrorPayload( + '"initialNamespaces" cannot be used on space-agnostic types' + ) + ) + ); + }); + + it(`returns error when initialNamespaces is empty`, async () => { + const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [] }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( + obj, + createBadRequestErrorPayload('"initialNamespaces" must be a non-empty array of strings') + ) + ); + }); + + it(`returns error when initialNamespaces is used with a space-isolated object and does not specify a single space`, async () => { + const doTest = async (objType: string, initialNamespaces: string[]) => { + const obj = { ...obj3, type: objType, initialNamespaces }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( + obj, + createBadRequestErrorPayload( + '"initialNamespaces" can only specify a single space when used with space-isolated types' + ) + ) + ); + }; + await doTest('dashboard', ['spacex', 'spacey']); + await doTest('dashboard', ['*']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['spacex', 'spacey']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['*']); + }); + + it(`returns error when type is invalid`, async () => { + const obj = { ...obj3, type: 'unknownType' }; + await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); + }); + + it(`returns error when type is hidden`, async () => { + const obj = { ...obj3, type: HIDDEN_TYPE }; + await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); + }); + + it(`returns error when there is a conflict from preflightCheckForCreate`, async () => { + const objects = [ + // only the second, third, and fourth objects are passed to preflightCheckForCreate and result in errors + obj1, + { ...obj1, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj3, type: MULTI_NAMESPACE_TYPE }, + obj2, + ]; + const [o1, o2, o3, o4, o5] = objects; + mockPreflightCheckForCreate.mockResolvedValueOnce([ + // first and last objects do not get passed in to preflightCheckForCreate at all + { type: o2.type, id: o2.id!, error: { type: 'conflict' } }, + { + type: o3.type, + id: o3.id!, + error: { type: 'unresolvableConflict', metadata: { isNotOverwritable: true } }, + }, + { + type: o4.type, + id: o4.id!, + error: { type: 'aliasConflict', metadata: { spacesWithConflictingAliases: ['foo'] } }, + }, + ]); + const bulkResponse = getMockBulkCreateResponse([o1, o5]); + client.bulk.mockResponseOnce(bulkResponse); + + const options = { overwrite: true }; + const result = await repository.bulkCreate(objects, options); + expect(mockPreflightCheckForCreate).toHaveBeenCalled(); + expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( + expect.objectContaining({ + objects: [ + { type: o2.type, id: o2.id, overwrite: true, namespaces: ['default'] }, + { type: o3.type, id: o3.id, overwrite: true, namespaces: ['default'] }, + { type: o4.type, id: o4.id, overwrite: true, namespaces: ['default'] }, + ], + }) + ); + expect(client.bulk).toHaveBeenCalled(); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body: [...expectObjArgs(o1), ...expectObjArgs(o5)] }), + expect.anything() + ); + expect(result).toEqual({ + saved_objects: [ + expectSuccess(o1), + expectErrorConflict(o2), + expectErrorConflict(o3, { metadata: { isNotOverwritable: true } }), + expectErrorConflict(o4, { metadata: { spacesWithConflictingAliases: ['foo'] } }), + expectSuccess(o5), + ], + }); + }); + + it(`returns bulk error`, async () => { + const expectedErrorResult = { + type: obj3.type, + id: obj3.id, + error: { error: 'Oh no, a bulk error!' }, + }; + await bulkCreateError(obj3, true, expectedErrorResult); + }); + + it(`returns errors for any bulk objects with invalid schemas`, async () => { + const response = getMockBulkCreateResponse([obj3]); + client.bulk.mockResponseOnce(response); + + const result = await repository.bulkCreate([ + obj3, + // @ts-expect-error - Title should be a string and is intentionally malformed for testing + { ...obj3, id: 'three-again', attributes: { title: 123 } }, + ]); + expect(client.bulk).toHaveBeenCalledTimes(1); // only called once for the valid object + expect(result.saved_objects).toEqual([ + expect.objectContaining(obj3), + expect.objectContaining({ + error: new Error( + '[attributes.title]: expected value of type [string] but got [number]: Bad Request' + ), + id: 'three-again', + type: 'dashboard', + }), + ]); + }); + }); + + describe('migration', () => { + it(`migrates the docs and serializes the migrated docs`, async () => { + migrator.migrateDocument.mockImplementation(mockMigrateDocument); + const modifiedObj1 = { ...obj1, coreMigrationVersion: '8.0.0' }; + await bulkCreateSuccess(client, repository, [modifiedObj1, obj2]); + const docs = [modifiedObj1, obj2].map((x) => ({ ...x, ...mockTimestampFieldsWithCreated })); + expectMigrationArgs(docs[0], true, 1); + expectMigrationArgs(docs[1], true, 2); + + const migratedDocs = docs.map((x) => migrator.migrateDocument(x)); + expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(1, migratedDocs[0]); + expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(2, migratedDocs[1]); + }); + + it(`adds namespace to body when providing namespace for single-namespace type`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); + expectMigrationArgs({ namespace }, true, 1); + expectMigrationArgs({ namespace }, true, 2); + }); + + it(`doesn't add namespace to body when providing no namespace for single-namespace type`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expectMigrationArgs({ namespace: expect.anything() }, false, 1); + expectMigrationArgs({ namespace: expect.anything() }, false, 2); + }); + + it(`doesn't add namespace to body when not using single-namespace type`, async () => { + const objects = [ + { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + ]; + await bulkCreateSuccess(client, repository, objects, { namespace }); + expectMigrationArgs({ namespace: expect.anything() }, false, 1); + expectMigrationArgs({ namespace: expect.anything() }, false, 2); + }); + + it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { + const objects = [obj1, obj2].map((obj) => ({ + ...obj, + type: MULTI_NAMESPACE_ISOLATED_TYPE, + })); + await bulkCreateSuccess(client, repository, objects, { namespace }); + expectMigrationArgs({ namespaces: [namespace] }, true, 1); + expectMigrationArgs({ namespaces: [namespace] }, true, 2); + }); + + it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { + const objects = [obj1, obj2].map((obj) => ({ + ...obj, + type: MULTI_NAMESPACE_ISOLATED_TYPE, + })); + await bulkCreateSuccess(client, repository, objects); + expectMigrationArgs({ namespaces: ['default'] }, true, 1); + expectMigrationArgs({ namespaces: ['default'] }, true, 2); + }); + + it(`doesn't add namespaces to body when not using multi-namespace type`, async () => { + const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; + await bulkCreateSuccess(client, repository, objects); + expectMigrationArgs({ namespaces: expect.anything() }, false, 1); + expectMigrationArgs({ namespaces: expect.anything() }, false, 2); + }); + }); + + describe('returns', () => { + it(`formats the ES response`, async () => { + const result = await bulkCreateSuccess(client, repository, [obj1, obj2]); + expect(result).toEqual({ + saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), + }); + }); + + it.todo(`should return objects in the same order regardless of type`); + + it(`handles a mix of successful creates and errors`, async () => { + const obj = { + type: 'unknownType', + id: 'three', + attributes: {}, + }; + const objects = [obj1, obj, obj2]; + const response = getMockBulkCreateResponse([obj1, obj2]); + client.bulk.mockResponseOnce(response); + const result = await repository.bulkCreate(objects); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + saved_objects: [expectCreateResult(obj1), expectError(obj), expectCreateResult(obj2)], + }); + }); + + it(`a deserialized saved object`, async () => { + // Test for fix to https://github.com/elastic/kibana/issues/65088 where + // we returned raw ID's when an object without an id was created. + const namespace = 'myspace'; + // FIXME: this test is based on a gigantic hack to have the bulk operation return the source + // of the document when it actually does not, forcing to cast to any as BulkResponse + // does not contains _source + const response = getMockBulkCreateResponse([obj1, obj2], namespace) as any; + client.bulk.mockResponseOnce(response); + + // Bulk create one object with id unspecified, and one with id specified + const result = await repository.bulkCreate([{ ...obj1, id: undefined }, obj2], { + namespace, + }); + + // Assert that both raw docs from the ES response are deserialized + expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith( + 1, + { + ...response.items[0].create, + _source: { + ...response.items[0].create._source, + namespaces: response.items[0].create._source.namespaces, + coreMigrationVersion: expect.any(String), + typeMigrationVersion: '1.1.1', + }, + _id: expect.stringMatching( + /^myspace:config:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/ + ), + }, + expect.any(Object) + ); + expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith( + 2, + { + ...response.items[1].create, + _source: { + ...response.items[1].create._source, + namespaces: response.items[1].create._source.namespaces, + coreMigrationVersion: expect.any(String), + typeMigrationVersion: '1.1.1', + }, + }, + expect.any(Object) + ); + + // Assert that ID's are deserialized to remove the type and namespace + expect(result.saved_objects[0].id).toEqual( + expect.stringMatching(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/) + ); + expect(result.saved_objects[1].id).toEqual(obj2.id); + + // Assert that managed is not changed + expect(result.saved_objects[0].managed).toBeFalsy(); + expect(result.saved_objects[1].managed).toEqual(obj2.managed); + }); + + it(`sets managed=false if not already set`, async () => { + const obj1WithoutManaged = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const obj2WithoutManaged = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + const result = await bulkCreateSuccess(client, repository, [ + obj1WithoutManaged, + obj2WithoutManaged, + ]); + expect(result).toEqual({ + saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), + }); + }); + + it(`sets managed=false only on documents without managed already set`, async () => { + const objWithoutManaged = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const result = await bulkCreateSuccess(client, repository, [objWithoutManaged, obj2]); + expect(result).toEqual({ + saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), + }); + }); + + it(`sets managed=true if provided as an override`, async () => { + const obj1WithoutManaged = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const obj2WithoutManaged = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + const result = await bulkCreateSuccess( + client, + repository, + [obj1WithoutManaged, obj2WithoutManaged], + { managed: true } + ); + expect(result).toEqual({ + saved_objects: [ + { ...obj1WithoutManaged, managed: true }, + { ...obj2WithoutManaged, managed: true }, + ].map((x) => expectCreateResult(x)), + }); + }); + + it(`sets managed=false if provided as an override`, async () => { + const obj1WithoutManaged = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const obj2WithoutManaged = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + const result = await bulkCreateSuccess( + client, + repository, + [obj1WithoutManaged, obj2WithoutManaged], + { managed: false } + ); + expect(result).toEqual({ + saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), + }); + }); + }); + }); +}); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts index 1084ad3e589661..3547d653e3de40 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts @@ -32,7 +32,6 @@ import type { SavedObjectsIncrementCounterOptions, SavedObjectsCreatePointInTimeFinderDependencies, SavedObjectsCreatePointInTimeFinderOptions, - SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, SavedObjectsCreateOptions, SavedObjectsDeleteOptions, @@ -86,13 +85,10 @@ import { getMockMgetResponse, type TypeIdTuple, createSpySerializer, - bulkCreateSuccess, - getMockBulkCreateResponse, bulkGet, expectErrorResult, expectErrorInvalidType, expectErrorNotFound, - expectErrorConflict, expectError, generateIndexPatternSearchResults, findSuccess, @@ -105,7 +101,6 @@ import { createUnsupportedTypeErrorPayload, createConflictErrorPayload, createGenericNotFoundErrorPayload, - expectCreateResult, mockTimestampFieldsWithCreated, getMockEsBulkDeleteResponse, bulkDeleteSuccess, @@ -193,917 +188,6 @@ describe('SavedObjectsRepository', () => { references: [{ name: 'search_0', type: 'search', id: '123' }], }); - describe('#bulkCreate', () => { - beforeEach(() => { - mockPreflightCheckForCreate.mockReset(); - mockPreflightCheckForCreate.mockImplementation(({ objects }) => { - return Promise.resolve(objects.map(({ type, id }) => ({ type, id }))); // respond with no errors by default - }); - }); - - const obj1 = { - type: 'config', - id: '6.0.0-alpha1', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - managed: false, - }; - const obj2 = { - type: 'index-pattern', - id: 'logstash-*', - attributes: { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - managed: false, - }; - const namespace = 'foo-namespace'; - - // bulk create calls have two objects for each source -- the action, and the source - const expectClientCallArgsAction = ( - objects: Array<{ type: string; id?: string; if_primary_term?: string; if_seq_no?: string }>, - { - method, - _index = expect.any(String), - getId = () => expect.any(String), - }: { method: string; _index?: string; getId?: (type: string, id?: string) => string } - ) => { - const body = []; - for (const { type, id, if_primary_term: ifPrimaryTerm, if_seq_no: ifSeqNo } of objects) { - body.push({ - [method]: { - _index, - _id: getId(type, id), - ...(ifPrimaryTerm && ifSeqNo - ? { if_primary_term: expect.any(Number), if_seq_no: expect.any(Number) } - : {}), - }, - }); - body.push(expect.any(Object)); - } - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }; - - const expectObjArgs = ( - { - type, - attributes, - references, - }: { type: string; attributes: unknown; references?: SavedObjectReference[] }, - overrides: Record = {} - ) => [ - expect.any(Object), - expect.objectContaining({ - [type]: attributes, - references, - type, - ...overrides, - ...mockTimestampFields, - }), - ]; - describe('client calls', () => { - it(`should use the ES bulk action by default`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expect(client.bulk).toHaveBeenCalledTimes(1); - }); - - it(`should use the preflightCheckForCreate action before bulk action for any types that are multi-namespace, when id is defined`, async () => { - const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; - await bulkCreateSuccess(client, repository, objects); - expect(client.bulk).toHaveBeenCalledTimes(1); - expect(mockPreflightCheckForCreate).toHaveBeenCalledTimes(1); - expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( - expect.objectContaining({ - objects: [ - { - type: MULTI_NAMESPACE_ISOLATED_TYPE, - id: obj2.id, - overwrite: false, - namespaces: ['default'], - }, - ], - }) - ); - }); - - 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(client, repository, objects, { overwrite: true }); - 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(client, repository, objects); - expectClientCallArgsAction(objects, { method: 'create' }); - }); - - it(`should use the ES index method if ID is defined and overwrite=true`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { overwrite: true }); - expectClientCallArgsAction([obj1, obj2], { method: 'index' }); - }); - - it(`should use the ES index method with version if ID and version are defined and overwrite=true`, async () => { - await bulkCreateSuccess( - client, - repository, - [ - { - ...obj1, - version: mockVersion, - }, - obj2, - ], - { overwrite: true } - ); - - const obj1WithSeq = { - ...obj1, - managed: obj1.managed, - if_seq_no: mockVersionProps._seq_no, - if_primary_term: mockVersionProps._primary_term, - }; - - expectClientCallArgsAction([obj1WithSeq, obj2], { method: 'index' }); - }); - - it(`should use the ES create method if ID is defined and overwrite=false`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expectClientCallArgsAction([obj1, obj2], { method: 'create' }); - }); - - it(`should use the ES index method if ID is defined, overwrite=true and managed=true in a document`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { - overwrite: true, - managed: true, - }); - expectClientCallArgsAction([obj1, obj2], { method: 'index' }); - }); - - it(`should use the ES create method if ID is defined, overwrite=false and managed=true in a document`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { managed: true }); - expectClientCallArgsAction([obj1, obj2], { method: 'create' }); - }); - - it(`formats the ES request`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - // this test only ensures that the client accepts the managed field in a document - it(`formats the ES request with managed=true in a document`, async () => { - const obj1WithManagedTrue = { ...obj1, managed: true }; - const obj2WithManagedTrue = { ...obj2, managed: true }; - await bulkCreateSuccess(client, repository, [obj1WithManagedTrue, obj2WithManagedTrue]); - const body = [...expectObjArgs(obj1WithManagedTrue), ...expectObjArgs(obj2WithManagedTrue)]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - describe('originId', () => { - it(`returns error if originId is set for non-multi-namespace type`, async () => { - const result = await repository.bulkCreate([ - { ...obj1, originId: 'some-originId' }, - { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE, originId: 'some-originId' }, - ]); - expect(result.saved_objects).toEqual([ - expect.objectContaining({ id: obj1.id, type: obj1.type, error: expect.anything() }), - expect.objectContaining({ - id: obj2.id, - type: NAMESPACE_AGNOSTIC_TYPE, - error: expect.anything(), - }), - ]); - expect(client.bulk).not.toHaveBeenCalled(); - }); - - it(`defaults to no originId`, async () => { - const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - ]; - - await bulkCreateSuccess(client, repository, objects); - const expected = expect.not.objectContaining({ originId: expect.anything() }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - describe('with existing originId', () => { - beforeEach(() => { - mockPreflightCheckForCreate.mockImplementation(({ objects }) => { - const existingDocument = { - _source: { originId: 'existing-originId' }, - } as SavedObjectsRawDoc; - return Promise.resolve( - objects.map(({ type, id }) => ({ type, id, existingDocument })) - ); - }); - }); - - it(`accepts custom originId for multi-namespace type`, async () => { - // The preflight result has `existing-originId`, but that is discarded - const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE, originId: 'some-originId' }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, originId: 'some-originId' }, - ]; - await bulkCreateSuccess(client, repository, objects); - const expected = expect.objectContaining({ originId: 'some-originId' }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - it(`accepts undefined originId`, async () => { - // The preflight result has `existing-originId`, but that is discarded - const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE, originId: undefined }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, originId: undefined }, - ]; - await bulkCreateSuccess(client, repository, objects); - const expected = expect.not.objectContaining({ originId: expect.anything() }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - it(`preserves existing originId if originId option is not set`, async () => { - const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - ]; - await bulkCreateSuccess(client, repository, objects); - const expected = expect.objectContaining({ originId: 'existing-originId' }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - 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(client, repository, [obj1, obj2], { namespace }); - const expected = expect.objectContaining({ namespace }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - // this only ensures we don't override any other options - it(`adds managed=false to request body if declared for any types that are single-namespace`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace, managed: false }); - const expected = expect.objectContaining({ namespace, managed: false }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - // this only ensures we don't override any other options - it(`adds managed=true to request body if declared for any types that are single-namespace`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace, managed: true }); - const expected = expect.objectContaining({ namespace, managed: true }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - it(`normalizes options.namespace from 'default' to undefined`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace: 'default' }); - const expected = expect.not.objectContaining({ namespace: 'default' }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - 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 () => { - const objects = [ - { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - ]; - await bulkCreateSuccess(client, repository, objects, { namespace }); - const expected = expect.not.objectContaining({ namespace: expect.anything() }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - it(`adds namespaces to request body for any types that are multi-namespace`, async () => { - const test = async (namespace?: string) => { - const objects = [obj1, obj2].map((x) => ({ ...x, type: MULTI_NAMESPACE_ISOLATED_TYPE })); - const [o1, o2] = objects; - mockPreflightCheckForCreate.mockResolvedValueOnce([ - { type: o1.type, id: o1.id! }, // first object does not have an existing document to overwrite - { - type: o2.type, - id: o2.id!, - existingDocument: { _id: o2.id!, _source: { namespaces: ['*'], type: o2.type } }, // second object does have an existing document to overwrite - }, - ]); - await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); - const expected1 = expect.objectContaining({ namespaces: [namespace ?? 'default'] }); - const expected2 = expect.objectContaining({ namespaces: ['*'] }); - const body = [expect.any(Object), expected1, expect.any(Object), expected2]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - client.bulk.mockClear(); - mockPreflightCheckForCreate.mockReset(); - }; - await test(undefined); - await test(namespace); - }); - - it(`adds initialNamespaces instead of namespace`, async () => { - const test = async (namespace?: string) => { - const ns2 = 'bar-namespace'; - const ns3 = 'baz-namespace'; - const objects = [ - { ...obj1, type: 'dashboard', initialNamespaces: [ns2] }, - { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE, initialNamespaces: [ns2] }, - { ...obj1, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns2, ns3] }, - ]; - const [o1, o2, o3] = objects; - mockPreflightCheckForCreate.mockResolvedValueOnce([ - // first object does not get passed in to preflightCheckForCreate at all - { type: o2.type, id: o2.id! }, // second object does not have an existing document to overwrite - { - type: o3.type, - id: o3.id!, - existingDocument: { - _id: o3.id!, - _source: { type: o3.type, namespaces: [namespace ?? 'default', 'something-else'] }, // third object does have an existing document to overwrite - }, - }, - ]); - await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); - const body = [ - { index: expect.objectContaining({ _id: `${ns2}:dashboard:${o1.id}` }) }, - expect.objectContaining({ namespace: ns2 }), - { - index: expect.objectContaining({ - _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${o2.id}`, - }), - }, - expect.objectContaining({ namespaces: [ns2] }), - { index: expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${o3.id}` }) }, - expect.objectContaining({ namespaces: [ns2, ns3] }), - ]; - expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( - expect.objectContaining({ - objects: [ - // assert that the initialNamespaces fields were passed into preflightCheckForCreate instead of the current namespace - { type: o2.type, id: o2.id, overwrite: true, namespaces: o2.initialNamespaces }, - { type: o3.type, id: o3.id, overwrite: true, namespaces: o3.initialNamespaces }, - ], - }) - ); - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - client.bulk.mockClear(); - mockPreflightCheckForCreate.mockReset(); - }; - await test(undefined); - await test(namespace); - }); - - it(`normalizes initialNamespaces from 'default' to undefined`, async () => { - const test = async (namespace?: string) => { - const objects = [{ ...obj1, type: 'dashboard', initialNamespaces: ['default'] }]; - await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); - const body = [ - { index: expect.objectContaining({ _id: `dashboard:${obj1.id}` }) }, - expect.not.objectContaining({ namespace: 'default' }), - ]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - client.bulk.mockClear(); - }; - await test(undefined); - await test(namespace); - }); - - it(`doesn't add namespaces to request body for any types that are not multi-namespace`, async () => { - const test = async (namespace?: string) => { - const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; - await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); - const expected = expect.not.objectContaining({ namespaces: expect.anything() }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - client.bulk.mockClear(); - }; - await test(undefined); - await test(namespace); - }); - - it(`defaults to a refresh setting of wait_for`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ refresh: 'wait_for' }), - expect.anything() - ); - }); - - it(`should use default index`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expectClientCallArgsAction([obj1, obj2], { - method: 'create', - _index: '.kibana-test_8.0.0-testing', - }); - }); - - it(`should use custom index`, async () => { - await bulkCreateSuccess( - client, - repository, - [obj1, obj2].map((x) => ({ ...x, type: CUSTOM_INDEX_TYPE })) - ); - expectClientCallArgsAction([obj1, obj2], { - method: 'create', - _index: 'custom_8.0.0-testing', - }); - }); - - it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { - const getId = (type: string, id: string = '') => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix) - await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); - 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: string, id: string = '') => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); - }); - - it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { - const getId = (type: string, id: string = '') => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) - const objects = [ - { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - ]; - await bulkCreateSuccess(client, repository, objects, { namespace }); - expectClientCallArgsAction(objects, { method: 'create', getId }); - }); - }); - - describe('errors', () => { - afterEach(() => { - mockGetBulkOperationError.mockReset(); - }); - - const obj3 = { - type: 'dashboard', - id: 'three', - attributes: { title: 'Test Three' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }; - - const bulkCreateError = async ( - obj: SavedObjectsBulkCreateObject, - isBulkError: boolean | undefined, - expectedErrorResult: ExpectedErrorResult - ) => { - let response; - if (isBulkError) { - // mock the bulk error for only the second object - mockGetBulkOperationError.mockReturnValueOnce(undefined); - mockGetBulkOperationError.mockReturnValueOnce(expectedErrorResult.error as Payload); - response = getMockBulkCreateResponse([obj1, obj, obj2]); - } else { - response = getMockBulkCreateResponse([obj1, obj2]); - } - client.bulk.mockResponseOnce(response); - - const objects = [obj1, obj, obj2]; - const result = await repository.bulkCreate(objects); - expect(client.bulk).toHaveBeenCalled(); - const objCall = isBulkError ? expectObjArgs(obj) : []; - const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - expect(result).toEqual({ - saved_objects: [expectSuccess(obj1), expectedErrorResult, expectSuccess(obj2)], - }); - }; - - it(`throws when options.namespace is '*'`, async () => { - await expect( - repository.bulkCreate([obj3], { namespace: ALL_NAMESPACES_STRING }) - ).rejects.toThrowError(createBadRequestErrorPayload('"options.namespace" cannot be "*"')); - }); - - it(`returns error when initialNamespaces is used with a space-agnostic object`, async () => { - const obj = { ...obj3, type: NAMESPACE_AGNOSTIC_TYPE, initialNamespaces: [] }; - await bulkCreateError( - obj, - undefined, - expectErrorResult( - obj, - createBadRequestErrorPayload( - '"initialNamespaces" cannot be used on space-agnostic types' - ) - ) - ); - }); - - it(`returns error when initialNamespaces is empty`, async () => { - const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [] }; - await bulkCreateError( - obj, - undefined, - expectErrorResult( - obj, - createBadRequestErrorPayload('"initialNamespaces" must be a non-empty array of strings') - ) - ); - }); - - it(`returns error when initialNamespaces is used with a space-isolated object and does not specify a single space`, async () => { - const doTest = async (objType: string, initialNamespaces: string[]) => { - const obj = { ...obj3, type: objType, initialNamespaces }; - await bulkCreateError( - obj, - undefined, - expectErrorResult( - obj, - createBadRequestErrorPayload( - '"initialNamespaces" can only specify a single space when used with space-isolated types' - ) - ) - ); - }; - await doTest('dashboard', ['spacex', 'spacey']); - await doTest('dashboard', ['*']); - await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['spacex', 'spacey']); - await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['*']); - }); - - it(`returns error when type is invalid`, async () => { - const obj = { ...obj3, type: 'unknownType' }; - await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); - }); - - it(`returns error when type is hidden`, async () => { - const obj = { ...obj3, type: HIDDEN_TYPE }; - await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); - }); - - it(`returns error when there is a conflict from preflightCheckForCreate`, async () => { - const objects = [ - // only the second, third, and fourth objects are passed to preflightCheckForCreate and result in errors - obj1, - { ...obj1, type: MULTI_NAMESPACE_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, - { ...obj3, type: MULTI_NAMESPACE_TYPE }, - obj2, - ]; - const [o1, o2, o3, o4, o5] = objects; - mockPreflightCheckForCreate.mockResolvedValueOnce([ - // first and last objects do not get passed in to preflightCheckForCreate at all - { type: o2.type, id: o2.id!, error: { type: 'conflict' } }, - { - type: o3.type, - id: o3.id!, - error: { type: 'unresolvableConflict', metadata: { isNotOverwritable: true } }, - }, - { - type: o4.type, - id: o4.id!, - error: { type: 'aliasConflict', metadata: { spacesWithConflictingAliases: ['foo'] } }, - }, - ]); - const bulkResponse = getMockBulkCreateResponse([o1, o5]); - client.bulk.mockResponseOnce(bulkResponse); - - const options = { overwrite: true }; - const result = await repository.bulkCreate(objects, options); - expect(mockPreflightCheckForCreate).toHaveBeenCalled(); - expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( - expect.objectContaining({ - objects: [ - { type: o2.type, id: o2.id, overwrite: true, namespaces: ['default'] }, - { type: o3.type, id: o3.id, overwrite: true, namespaces: ['default'] }, - { type: o4.type, id: o4.id, overwrite: true, namespaces: ['default'] }, - ], - }) - ); - expect(client.bulk).toHaveBeenCalled(); - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body: [...expectObjArgs(o1), ...expectObjArgs(o5)] }), - expect.anything() - ); - expect(result).toEqual({ - saved_objects: [ - expectSuccess(o1), - expectErrorConflict(o2), - expectErrorConflict(o3, { metadata: { isNotOverwritable: true } }), - expectErrorConflict(o4, { metadata: { spacesWithConflictingAliases: ['foo'] } }), - expectSuccess(o5), - ], - }); - }); - - it(`returns bulk error`, async () => { - const expectedErrorResult = { - type: obj3.type, - id: obj3.id, - error: { error: 'Oh no, a bulk error!' }, - }; - await bulkCreateError(obj3, true, expectedErrorResult); - }); - - it(`returns errors for any bulk objects with invalid schemas`, async () => { - const response = getMockBulkCreateResponse([obj3]); - client.bulk.mockResponseOnce(response); - - const result = await repository.bulkCreate([ - obj3, - // @ts-expect-error - Title should be a string and is intentionally malformed for testing - { ...obj3, id: 'three-again', attributes: { title: 123 } }, - ]); - expect(client.bulk).toHaveBeenCalledTimes(1); // only called once for the valid object - expect(result.saved_objects).toEqual([ - expect.objectContaining(obj3), - expect.objectContaining({ - error: new Error( - '[attributes.title]: expected value of type [string] but got [number]: Bad Request' - ), - id: 'three-again', - type: 'dashboard', - }), - ]); - }); - }); - - describe('migration', () => { - it(`migrates the docs and serializes the migrated docs`, async () => { - migrator.migrateDocument.mockImplementation(mockMigrateDocument); - const modifiedObj1 = { ...obj1, coreMigrationVersion: '8.0.0' }; - await bulkCreateSuccess(client, repository, [modifiedObj1, obj2]); - const docs = [modifiedObj1, obj2].map((x) => ({ ...x, ...mockTimestampFieldsWithCreated })); - expectMigrationArgs(docs[0], true, 1); - expectMigrationArgs(docs[1], true, 2); - - const migratedDocs = docs.map((x) => migrator.migrateDocument(x)); - expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(1, migratedDocs[0]); - expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(2, migratedDocs[1]); - }); - - it(`adds namespace to body when providing namespace for single-namespace type`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); - expectMigrationArgs({ namespace }, true, 1); - expectMigrationArgs({ namespace }, true, 2); - }); - - it(`doesn't add namespace to body when providing no namespace for single-namespace type`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expectMigrationArgs({ namespace: expect.anything() }, false, 1); - expectMigrationArgs({ namespace: expect.anything() }, false, 2); - }); - - it(`doesn't add namespace to body when not using single-namespace type`, async () => { - const objects = [ - { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - ]; - await bulkCreateSuccess(client, repository, objects, { namespace }); - expectMigrationArgs({ namespace: expect.anything() }, false, 1); - expectMigrationArgs({ namespace: expect.anything() }, false, 2); - }); - - it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ - ...obj, - type: MULTI_NAMESPACE_ISOLATED_TYPE, - })); - await bulkCreateSuccess(client, repository, objects, { namespace }); - expectMigrationArgs({ namespaces: [namespace] }, true, 1); - expectMigrationArgs({ namespaces: [namespace] }, true, 2); - }); - - it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ - ...obj, - type: MULTI_NAMESPACE_ISOLATED_TYPE, - })); - await bulkCreateSuccess(client, repository, objects); - expectMigrationArgs({ namespaces: ['default'] }, true, 1); - expectMigrationArgs({ namespaces: ['default'] }, true, 2); - }); - - it(`doesn't add namespaces to body when not using multi-namespace type`, async () => { - const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; - await bulkCreateSuccess(client, repository, objects); - expectMigrationArgs({ namespaces: expect.anything() }, false, 1); - expectMigrationArgs({ namespaces: expect.anything() }, false, 2); - }); - }); - - describe('returns', () => { - it(`formats the ES response`, async () => { - const result = await bulkCreateSuccess(client, repository, [obj1, obj2]); - expect(result).toEqual({ - saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), - }); - }); - - it.todo(`should return objects in the same order regardless of type`); - - it(`handles a mix of successful creates and errors`, async () => { - const obj = { - type: 'unknownType', - id: 'three', - attributes: {}, - }; - const objects = [obj1, obj, obj2]; - const response = getMockBulkCreateResponse([obj1, obj2]); - client.bulk.mockResponseOnce(response); - const result = await repository.bulkCreate(objects); - expect(client.bulk).toHaveBeenCalledTimes(1); - expect(result).toEqual({ - saved_objects: [expectCreateResult(obj1), expectError(obj), expectCreateResult(obj2)], - }); - }); - - it(`a deserialized saved object`, async () => { - // Test for fix to https://github.com/elastic/kibana/issues/65088 where - // we returned raw ID's when an object without an id was created. - const namespace = 'myspace'; - // FIXME: this test is based on a gigantic hack to have the bulk operation return the source - // of the document when it actually does not, forcing to cast to any as BulkResponse - // does not contains _source - const response = getMockBulkCreateResponse([obj1, obj2], namespace) as any; - client.bulk.mockResponseOnce(response); - - // Bulk create one object with id unspecified, and one with id specified - const result = await repository.bulkCreate([{ ...obj1, id: undefined }, obj2], { - namespace, - }); - - // Assert that both raw docs from the ES response are deserialized - expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith( - 1, - { - ...response.items[0].create, - _source: { - ...response.items[0].create._source, - namespaces: response.items[0].create._source.namespaces, - coreMigrationVersion: expect.any(String), - typeMigrationVersion: '1.1.1', - }, - _id: expect.stringMatching( - /^myspace:config:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/ - ), - }, - expect.any(Object) - ); - expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith( - 2, - { - ...response.items[1].create, - _source: { - ...response.items[1].create._source, - namespaces: response.items[1].create._source.namespaces, - coreMigrationVersion: expect.any(String), - typeMigrationVersion: '1.1.1', - }, - }, - expect.any(Object) - ); - - // Assert that ID's are deserialized to remove the type and namespace - expect(result.saved_objects[0].id).toEqual( - expect.stringMatching(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/) - ); - expect(result.saved_objects[1].id).toEqual(obj2.id); - - // Assert that managed is not changed - expect(result.saved_objects[0].managed).toBeFalsy(); - expect(result.saved_objects[1].managed).toEqual(obj2.managed); - }); - - it(`sets managed=false if not already set`, async () => { - const obj1WithoutManaged = { - type: 'config', - id: '6.0.0-alpha1', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }; - const obj2WithoutManaged = { - type: 'index-pattern', - id: 'logstash-*', - attributes: { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }; - const result = await bulkCreateSuccess(client, repository, [ - obj1WithoutManaged, - obj2WithoutManaged, - ]); - expect(result).toEqual({ - saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), - }); - }); - - it(`sets managed=false only on documents without managed already set`, async () => { - const objWithoutManaged = { - type: 'config', - id: '6.0.0-alpha1', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }; - const result = await bulkCreateSuccess(client, repository, [objWithoutManaged, obj2]); - expect(result).toEqual({ - saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), - }); - }); - - it(`sets managed=true if provided as an override`, async () => { - const obj1WithoutManaged = { - type: 'config', - id: '6.0.0-alpha1', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }; - const obj2WithoutManaged = { - type: 'index-pattern', - id: 'logstash-*', - attributes: { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }; - const result = await bulkCreateSuccess( - client, - repository, - [obj1WithoutManaged, obj2WithoutManaged], - { managed: true } - ); - expect(result).toEqual({ - saved_objects: [ - { ...obj1WithoutManaged, managed: true }, - { ...obj2WithoutManaged, managed: true }, - ].map((x) => expectCreateResult(x)), - }); - }); - - it(`sets managed=false if provided as an override`, async () => { - const obj1WithoutManaged = { - type: 'config', - id: '6.0.0-alpha1', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }; - const obj2WithoutManaged = { - type: 'index-pattern', - id: 'logstash-*', - attributes: { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }; - const result = await bulkCreateSuccess( - client, - repository, - [obj1WithoutManaged, obj2WithoutManaged], - { managed: false } - ); - expect(result).toEqual({ - saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), - }); - }); - }); - }); - describe('#bulkGet', () => { const obj1: SavedObject = { type: 'config', From 52561080756d2da77fec6728febb417acee6fe9d Mon Sep 17 00:00:00 2001 From: Alex Szabo Date: Tue, 21 Nov 2023 16:02:58 +0100 Subject: [PATCH 04/28] Skip flaky endpoint tests (#171658) ## Summary Several cases of flakiness on main: // https://github.com/elastic/kibana/issues/171655 // https://github.com/elastic/kibana/issues/171656 // https://github.com/elastic/kibana/issues/171647 // https://github.com/elastic/kibana/issues/171648 --- .../apis/endpoint_authz.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts index 7434f46ca35be6..79f964f8c42713 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts @@ -40,7 +40,12 @@ export default function ({ getService }: FtrProviderContext) { body: Record | undefined; } - describe('When attempting to call an endpoint api', function () { + // Flaky: + // https://github.com/elastic/kibana/issues/171655 + // https://github.com/elastic/kibana/issues/171656 + // https://github.com/elastic/kibana/issues/171647 + // https://github.com/elastic/kibana/issues/171648 + describe.skip('When attempting to call an endpoint api', function () { targetTags(this, ['@ess', '@serverless']); let indexedData: IndexedHostsAndAlertsResponse; From 39af7880678843beb77952fbbad584d6d77ca2bb Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Tue, 21 Nov 2023 08:50:38 -0700 Subject: [PATCH 05/28] [ML] Trained models list: disable 'View training data' action if data frame analytics job no longer exists (#171061) ## Summary Fixes https://github.com/elastic/kibana/issues/167667, disabling the 'View training data' action for models in the Trained Models list if the data frame analytics job which created the model no longer exists Adds `origin_job_exists` property to trained models list model items. This is set during the models fetch for models with associated data frame analytics jobs. ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/ml/common/types/trained_models.ts | 1 + .../model_management/model_actions.tsx | 6 +-- .../model_management/models_list.tsx | 1 + .../ml/server/routes/trained_models.ts | 43 +++++++++++++++---- 4 files changed, 40 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/ml/common/types/trained_models.ts b/x-pack/plugins/ml/common/types/trained_models.ts index 70f588712c3518..95815cd3b87af0 100644 --- a/x-pack/plugins/ml/common/types/trained_models.ts +++ b/x-pack/plugins/ml/common/types/trained_models.ts @@ -98,6 +98,7 @@ export type TrainedModelConfigResponse = estypes.MlTrainedModelConfig & { * Associated pipelines. Extends response from the ES endpoint. */ pipelines?: Record | null; + origin_job_exists?: boolean; metadata?: { analytics_config: DataFrameAnalyticsConfig; diff --git a/x-pack/plugins/ml/public/application/model_management/model_actions.tsx b/x-pack/plugins/ml/public/application/model_management/model_actions.tsx index 7c39528cf5b4a0..42e095edeccf54 100644 --- a/x-pack/plugins/ml/public/application/model_management/model_actions.tsx +++ b/x-pack/plugins/ml/public/application/model_management/model_actions.tsx @@ -130,18 +130,19 @@ export function useModelActions({ return useMemo( () => [ { - name: i18n.translate('xpack.ml.trainedModels.modelsList.viewTrainingDataActionLabel', { + name: i18n.translate('xpack.ml.trainedModels.modelsList.viewTrainingDataNameActionLabel', { defaultMessage: 'View training data', }), description: i18n.translate( 'xpack.ml.trainedModels.modelsList.viewTrainingDataActionLabel', { - defaultMessage: 'View training data', + defaultMessage: 'Training data can be viewed when data frame analytics job exists.', } ), icon: 'visTable', type: 'icon', available: (item) => !!item.metadata?.analytics_config?.id, + enabled: (item) => item.origin_job_exists === true, onClick: async (item) => { if (item.metadata?.analytics_config === undefined) return; @@ -164,7 +165,6 @@ export function useModelActions({ await navigateToUrl(url); }, - isPrimary: true, }, { name: i18n.translate('xpack.ml.inference.modelsList.analyticsMapActionLabel', { diff --git a/x-pack/plugins/ml/public/application/model_management/models_list.tsx b/x-pack/plugins/ml/public/application/model_management/models_list.tsx index 56486d1bbbd4f8..e9672729b6f4f6 100644 --- a/x-pack/plugins/ml/public/application/model_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/model_management/models_list.tsx @@ -79,6 +79,7 @@ export type ModelItem = TrainedModelConfigResponse & { type?: string[]; stats?: Stats & { deployment_stats: TrainedModelDeploymentStatsResponse[] }; pipelines?: ModelPipelines['pipelines'] | null; + origin_job_exists?: boolean; deployment_ids: string[]; putModelConfig?: object; state: ModelState; diff --git a/x-pack/plugins/ml/server/routes/trained_models.ts b/x-pack/plugins/ml/server/routes/trained_models.ts index 8095411f911e7a..34cbaf755c1e1c 100644 --- a/x-pack/plugins/ml/server/routes/trained_models.ts +++ b/x-pack/plugins/ml/server/routes/trained_models.ts @@ -29,9 +29,9 @@ import { createIngestPipelineSchema, modelDownloadsQuery, } from './schemas/inference_schema'; -import type { +import { PipelineDefinition, - TrainedModelConfigResponse, + type TrainedModelConfigResponse, } from '../../common/types/trained_models'; import { mlLog } from '../lib/log'; import { forceQuerySchema } from './schemas/anomaly_detectors_schema'; @@ -39,10 +39,9 @@ import { modelsProvider } from '../models/model_management'; export const DEFAULT_TRAINED_MODELS_PAGE_SIZE = 10000; -export function filterForEnabledFeatureModels( - models: TrainedModelConfigResponse[] | estypes.MlTrainedModelConfig[], - enabledFeatures: MlFeatures -) { +export function filterForEnabledFeatureModels< + T extends TrainedModelConfigResponse | estypes.MlTrainedModelConfig +>(models: T[], enabledFeatures: MlFeatures) { let filteredModels = models; if (enabledFeatures.nlp === false) { filteredModels = filteredModels.filter((m) => m.model_type === 'tree_ensemble'); @@ -191,10 +190,38 @@ export function trainedModelsRoutes( mlLog.debug(e); } - const body = filterForEnabledFeatureModels(result, getEnabledFeatures()); + const filteredModels = filterForEnabledFeatureModels(result, getEnabledFeatures()); + + try { + const jobIds = filteredModels + .map((model) => { + const id = model.metadata?.analytics_config?.id; + if (id) { + return `${id}*`; + } + }) + .filter((id) => id !== undefined); + + if (jobIds.length) { + const { data_frame_analytics: jobs } = await mlClient.getDataFrameAnalytics({ + id: jobIds.join(','), + allow_no_match: true, + }); + + filteredModels.forEach((model) => { + const dfaId = model?.metadata?.analytics_config?.id; + if (dfaId !== undefined) { + // if this is a dfa model, set origin_job_exists + model.origin_job_exists = jobs.find((job) => job.id === dfaId) !== undefined; + } + }); + } + } catch (e) { + // Swallow error to prevent blocking trained models result + } return response.ok({ - body, + body: filteredModels, }); } catch (e) { return response.customError(wrapError(e)); From 5e3b124ae060aa339ee715e1afbab21cb6fad2e3 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Tue, 21 Nov 2023 15:52:12 +0000 Subject: [PATCH 06/28] [ML] Create categorization job from pattern analysis (#170567) Adds the ability to quickly create a categorisation anomaly detection job from the pattern analysis flyout. Adds a new `created_by` ID `categorization-wizard-from-pattern-analysis` which can be picked up by telemetry. Creates a new package for sharing our AIOPs ui actions IDs. I think we should move the pattern analysis ID to this package too, but that can be done in a separate PR. https://github.com/elastic/kibana/assets/22172091/51349f93-f072-4983-85f0-98741902fb5a https://github.com/elastic/kibana/assets/22172091/6e618581-8916-4e63-930f-945c96c25e6c --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 1 + package.json | 1 + tsconfig.base.json | 2 + .../components/full_time_range_selector.tsx | 2 +- .../full_time_range_selector_service.ts | 2 +- .../src/services/time_field_range.ts | 2 +- .../src/add_exclude_frozen_to_query.ts | 2 +- x-pack/packages/ml/ui_actions/README.md | 3 + x-pack/packages/ml/ui_actions/index.ts | 12 + x-pack/packages/ml/ui_actions/jest.config.js | 12 + x-pack/packages/ml/ui_actions/kibana.jsonc | 5 + x-pack/packages/ml/ui_actions/package.json | 6 + .../packages/ml/ui_actions/src/ui_actions.ts | 22 ++ x-pack/packages/ml/ui_actions/tsconfig.json | 20 ++ .../create_categorization_job.tsx | 76 +++++ .../log_categorization_for_flyout.tsx | 11 +- .../log_categorization/show_flyout.tsx | 4 +- .../public/hooks/use_aiops_app_context.ts | 5 + x-pack/plugins/aiops/tsconfig.json | 1 + x-pack/plugins/ml/common/constants/locator.ts | 1 + x-pack/plugins/ml/common/constants/new_job.ts | 1 + x-pack/plugins/ml/common/types/locator.ts | 1 + .../aiops/change_point_detection.tsx | 1 + .../application/aiops/log_categorization.tsx | 1 + .../application/aiops/log_rate_analysis.tsx | 1 + .../anomalies_table/anomaly_details.tsx | 2 +- .../quick_create_job_base.ts | 10 +- .../new_job/job_from_lens/quick_create_job.ts | 2 +- .../new_job/job_from_lens/route_resolver.ts | 42 +-- .../new_job/job_from_map/quick_create_job.ts | 2 +- .../new_job/job_from_map/route_resolver.ts | 89 ++---- .../job_from_pattern_analysis/index.ts | 14 + .../quick_create_job.ts | 209 ++++++++++++++ .../route_resolver.ts | 71 +++++ .../job_from_pattern_analysis/utils.ts | 43 +++ .../preconfigured_job_redirect.ts | 1 + .../jobs/new_job/utils/new_job_utils.ts | 12 + .../routes/new_job/from_pattern_analysis.tsx | 71 +++++ .../routing/routes/new_job/index.ts | 1 + .../new_job_capabilities_service.ts | 17 +- .../job_creation/aiops/flyout/create_job.tsx | 273 ++++++++++++++++++ .../job_creation/aiops/flyout/flyout.tsx | 79 +++++ .../job_creation/aiops/flyout/index.ts | 8 + .../embeddables/job_creation/aiops/index.ts | 8 + .../job_creation/aiops/show_flyout.tsx | 41 +++ .../job_creation/{lens => common}/context.ts | 0 .../job_creation/common/create_flyout.tsx | 8 +- .../job_creation/common/job_details.tsx | 17 +- .../flyout.tsx | 2 +- .../layer/compatible_layer.tsx | 3 +- .../job_creation/lens/show_flyout.tsx | 18 +- .../layer/compatible_layer.tsx | 3 +- .../job_creation/map/show_flyout.tsx | 8 +- .../plugins/ml/public/locator/ml_locator.ts | 1 + x-pack/plugins/ml/public/ui_actions/index.ts | 12 + .../open_create_categorization_job_action.tsx | 70 +++++ .../ui_actions/open_vis_in_ml_action.tsx | 2 +- x-pack/plugins/ml/tsconfig.json | 1 + yarn.lock | 4 + 59 files changed, 1194 insertions(+), 145 deletions(-) create mode 100644 x-pack/packages/ml/ui_actions/README.md create mode 100644 x-pack/packages/ml/ui_actions/index.ts create mode 100644 x-pack/packages/ml/ui_actions/jest.config.js create mode 100644 x-pack/packages/ml/ui_actions/kibana.jsonc create mode 100644 x-pack/packages/ml/ui_actions/package.json create mode 100644 x-pack/packages/ml/ui_actions/src/ui_actions.ts create mode 100644 x-pack/packages/ml/ui_actions/tsconfig.json create mode 100644 x-pack/plugins/aiops/public/components/log_categorization/create_categorization_job.tsx create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_pattern_analysis/index.ts create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_pattern_analysis/quick_create_job.ts create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_pattern_analysis/route_resolver.ts create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/job_from_pattern_analysis/utils.ts create mode 100644 x-pack/plugins/ml/public/application/routing/routes/new_job/from_pattern_analysis.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/job_creation/aiops/flyout/create_job.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/job_creation/aiops/flyout/flyout.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/job_creation/aiops/flyout/index.ts create mode 100644 x-pack/plugins/ml/public/embeddables/job_creation/aiops/index.ts create mode 100644 x-pack/plugins/ml/public/embeddables/job_creation/aiops/show_flyout.tsx rename x-pack/plugins/ml/public/embeddables/job_creation/{lens => common}/context.ts (100%) create mode 100644 x-pack/plugins/ml/public/ui_actions/open_create_categorization_job_action.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 994b92442d00d3..70affc4d4b4005 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -536,6 +536,7 @@ x-pack/packages/ml/route_utils @elastic/ml-ui x-pack/packages/ml/runtime_field_utils @elastic/ml-ui x-pack/packages/ml/string_hash @elastic/ml-ui x-pack/packages/ml/trained_models_utils @elastic/ml-ui +x-pack/packages/ml/ui_actions @elastic/ml-ui x-pack/packages/ml/url_state @elastic/ml-ui packages/kbn-monaco @elastic/appex-sharedux x-pack/plugins/monitoring_collection @elastic/obs-ux-infra_services-team diff --git a/package.json b/package.json index b2ef6e211107c1..73ae2cc3885434 100644 --- a/package.json +++ b/package.json @@ -555,6 +555,7 @@ "@kbn/ml-runtime-field-utils": "link:x-pack/packages/ml/runtime_field_utils", "@kbn/ml-string-hash": "link:x-pack/packages/ml/string_hash", "@kbn/ml-trained-models-utils": "link:x-pack/packages/ml/trained_models_utils", + "@kbn/ml-ui-actions": "link:x-pack/packages/ml/ui_actions", "@kbn/ml-url-state": "link:x-pack/packages/ml/url_state", "@kbn/monaco": "link:packages/kbn-monaco", "@kbn/monitoring-collection-plugin": "link:x-pack/plugins/monitoring_collection", diff --git a/tsconfig.base.json b/tsconfig.base.json index 06ade5904efadd..2d9419b2b7712e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1066,6 +1066,8 @@ "@kbn/ml-string-hash/*": ["x-pack/packages/ml/string_hash/*"], "@kbn/ml-trained-models-utils": ["x-pack/packages/ml/trained_models_utils"], "@kbn/ml-trained-models-utils/*": ["x-pack/packages/ml/trained_models_utils/*"], + "@kbn/ml-ui-actions": ["x-pack/packages/ml/ui_actions"], + "@kbn/ml-ui-actions/*": ["x-pack/packages/ml/ui_actions/*"], "@kbn/ml-url-state": ["x-pack/packages/ml/url_state"], "@kbn/ml-url-state/*": ["x-pack/packages/ml/url_state/*"], "@kbn/monaco": ["packages/kbn-monaco"], diff --git a/x-pack/packages/ml/date_picker/src/components/full_time_range_selector.tsx b/x-pack/packages/ml/date_picker/src/components/full_time_range_selector.tsx index 5b2a9d880c1b8e..cb890172bbdd9f 100644 --- a/x-pack/packages/ml/date_picker/src/components/full_time_range_selector.tsx +++ b/x-pack/packages/ml/date_picker/src/components/full_time_range_selector.tsx @@ -7,7 +7,7 @@ import React, { FC, useCallback, useMemo, useState } from 'react'; -import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { EuiButton, diff --git a/x-pack/packages/ml/date_picker/src/services/full_time_range_selector_service.ts b/x-pack/packages/ml/date_picker/src/services/full_time_range_selector_service.ts index cfd363e8b53ce2..c4cdd23995ae77 100644 --- a/x-pack/packages/ml/date_picker/src/services/full_time_range_selector_service.ts +++ b/x-pack/packages/ml/date_picker/src/services/full_time_range_selector_service.ts @@ -8,7 +8,7 @@ import moment from 'moment'; import type { TimefilterContract } from '@kbn/data-plugin/public'; import dateMath from '@kbn/datemath'; -import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { i18n } from '@kbn/i18n'; import type { ToastsStart, HttpStart } from '@kbn/core/public'; import type { DataView } from '@kbn/data-views-plugin/public'; diff --git a/x-pack/packages/ml/date_picker/src/services/time_field_range.ts b/x-pack/packages/ml/date_picker/src/services/time_field_range.ts index 07a5fb3de3553e..92d71f582a1ef6 100644 --- a/x-pack/packages/ml/date_picker/src/services/time_field_range.ts +++ b/x-pack/packages/ml/date_picker/src/services/time_field_range.ts @@ -6,7 +6,7 @@ */ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import type { HttpStart } from '@kbn/core/public'; diff --git a/x-pack/packages/ml/query_utils/src/add_exclude_frozen_to_query.ts b/x-pack/packages/ml/query_utils/src/add_exclude_frozen_to_query.ts index 71625460349641..3148e93bed02f0 100644 --- a/x-pack/packages/ml/query_utils/src/add_exclude_frozen_to_query.ts +++ b/x-pack/packages/ml/query_utils/src/add_exclude_frozen_to_query.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { cloneDeep } from 'lodash'; import { isPopulatedObject } from '@kbn/ml-is-populated-object'; diff --git a/x-pack/packages/ml/ui_actions/README.md b/x-pack/packages/ml/ui_actions/README.md new file mode 100644 index 00000000000000..a9cba4a278c8ea --- /dev/null +++ b/x-pack/packages/ml/ui_actions/README.md @@ -0,0 +1,3 @@ +# @kbn/ml-ui-actions + +Empty package generated by @kbn/generate diff --git a/x-pack/packages/ml/ui_actions/index.ts b/x-pack/packages/ml/ui_actions/index.ts new file mode 100644 index 00000000000000..8077406b586754 --- /dev/null +++ b/x-pack/packages/ml/ui_actions/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { + CREATE_PATTERN_ANALYSIS_TO_ML_AD_JOB_ACTION, + CREATE_PATTERN_ANALYSIS_TO_ML_AD_JOB_TRIGGER, + type CreateCategorizationADJobContext, +} from './src/ui_actions'; diff --git a/x-pack/packages/ml/ui_actions/jest.config.js b/x-pack/packages/ml/ui_actions/jest.config.js new file mode 100644 index 00000000000000..8efadb42705fa8 --- /dev/null +++ b/x-pack/packages/ml/ui_actions/jest.config.js @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../../../..', + roots: ['/x-pack/packages/ml/ui_actions'], +}; diff --git a/x-pack/packages/ml/ui_actions/kibana.jsonc b/x-pack/packages/ml/ui_actions/kibana.jsonc new file mode 100644 index 00000000000000..999f955bc2e471 --- /dev/null +++ b/x-pack/packages/ml/ui_actions/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/ml-ui-actions", + "owner": "@elastic/ml-ui" +} diff --git a/x-pack/packages/ml/ui_actions/package.json b/x-pack/packages/ml/ui_actions/package.json new file mode 100644 index 00000000000000..a3d77f4701c68d --- /dev/null +++ b/x-pack/packages/ml/ui_actions/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/ml-ui-actions", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0" +} \ No newline at end of file diff --git a/x-pack/packages/ml/ui_actions/src/ui_actions.ts b/x-pack/packages/ml/ui_actions/src/ui_actions.ts new file mode 100644 index 00000000000000..cf36e3ff01ce66 --- /dev/null +++ b/x-pack/packages/ml/ui_actions/src/ui_actions.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; +import type { TimeRange } from '@kbn/es-query'; + +export interface CreateCategorizationADJobContext { + field: DataViewField; + dataView: DataView; + query: QueryDslQueryContainer; + timeRange: TimeRange; +} + +export const CREATE_PATTERN_ANALYSIS_TO_ML_AD_JOB_ACTION = 'createMLADCategorizationJobAction'; + +export const CREATE_PATTERN_ANALYSIS_TO_ML_AD_JOB_TRIGGER = + 'CREATE_PATTERN_ANALYSIS_TO_ML_AD_JOB_TRIGGER'; diff --git a/x-pack/packages/ml/ui_actions/tsconfig.json b/x-pack/packages/ml/ui_actions/tsconfig.json new file mode 100644 index 00000000000000..25ae95b59b1638 --- /dev/null +++ b/x-pack/packages/ml/ui_actions/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/data-views-plugin", + "@kbn/es-query", + ] +} diff --git a/x-pack/plugins/aiops/public/components/log_categorization/create_categorization_job.tsx b/x-pack/plugins/aiops/public/components/log_categorization/create_categorization_job.tsx new file mode 100644 index 00000000000000..66458c511d981a --- /dev/null +++ b/x-pack/plugins/aiops/public/components/log_categorization/create_categorization_job.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; + +import moment from 'moment'; +import { EuiButtonEmpty } from '@elastic/eui'; +import type { DataViewField, DataView } from '@kbn/data-views-plugin/common'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { + CREATE_PATTERN_ANALYSIS_TO_ML_AD_JOB_TRIGGER, + type CreateCategorizationADJobContext, +} from '@kbn/ml-ui-actions'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; + +interface Props { + dataView: DataView; + field: DataViewField; + query: QueryDslQueryContainer; + earliest: number | undefined; + latest: number | undefined; +} + +export const CreateCategorizationJobButton: FC = ({ + dataView, + field, + query, + earliest, + latest, +}) => { + const { + uiActions, + application: { capabilities }, + } = useAiopsAppContext(); + + const createADJob = () => { + if (uiActions === undefined) { + return; + } + + const triggerOptions: CreateCategorizationADJobContext = { + dataView, + field, + query, + timeRange: { from: moment(earliest).toISOString(), to: moment(latest).toISOString() }, + }; + uiActions.getTrigger(CREATE_PATTERN_ANALYSIS_TO_ML_AD_JOB_TRIGGER).exec(triggerOptions); + }; + + if (uiActions === undefined || capabilities.ml.canCreateJob === false) { + return null; + } + + return ( + <> + + + + + ); +}; diff --git a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_flyout.tsx b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_flyout.tsx index a5262393e0eec8..5b37fd019c013c 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_flyout.tsx +++ b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_for_flyout.tsx @@ -44,6 +44,7 @@ import { TechnicalPreviewBadge } from './technical_preview_badge'; import { LoadingCategorization } from './loading_categorization'; import { useValidateFieldRequest } from './use_validate_category_field'; import { FieldValidationCallout } from './category_validation_callout'; +import { CreateCategorizationJobButton } from './create_categorization_job'; export interface LogCategorizationPageProps { dataView: DataView; @@ -261,17 +262,21 @@ export const LogCategorizationFlyout: FC = ({ + - {loading === true ? : null} - - {loading === false && data !== null && data.categories.length > 0 ? ( { 'share', 'storage', 'theme', + 'uiActions', 'uiSettings', 'unifiedSearch', 'usageCollection', diff --git a/x-pack/plugins/ml/public/application/aiops/log_categorization.tsx b/x-pack/plugins/ml/public/application/aiops/log_categorization.tsx index 455ff9bfc13774..187843505cef7e 100644 --- a/x-pack/plugins/ml/public/application/aiops/log_categorization.tsx +++ b/x-pack/plugins/ml/public/application/aiops/log_categorization.tsx @@ -56,6 +56,7 @@ export const LogCategorizationPage: FC = () => { 'share', 'storage', 'theme', + 'uiActions', 'uiSettings', 'unifiedSearch', ])} diff --git a/x-pack/plugins/ml/public/application/aiops/log_rate_analysis.tsx b/x-pack/plugins/ml/public/application/aiops/log_rate_analysis.tsx index c20264a129ea31..4c2c1dfd637dbc 100644 --- a/x-pack/plugins/ml/public/application/aiops/log_rate_analysis.tsx +++ b/x-pack/plugins/ml/public/application/aiops/log_rate_analysis.tsx @@ -59,6 +59,7 @@ export const LogRateAnalysisPage: FC = () => { 'share', 'storage', 'theme', + 'uiActions', 'uiSettings', 'unifiedSearch', ])} diff --git a/x-pack/plugins/ml/public/application/components/anomalies_table/anomaly_details.tsx b/x-pack/plugins/ml/public/application/components/anomalies_table/anomaly_details.tsx index 556dd5c810d842..eb5450d0d61bff 100644 --- a/x-pack/plugins/ml/public/application/components/anomalies_table/anomaly_details.tsx +++ b/x-pack/plugins/ml/public/application/components/anomalies_table/anomaly_details.tsx @@ -361,7 +361,7 @@ const CategoryExamples: FC<{ definition: CategoryDefinition; examples: string[] {definition !== undefined && definition.terms && ( diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_dashboard/quick_create_job_base.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_dashboard/quick_create_job_base.ts index 4b4ce729927756..f5f491426b0a3a 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_dashboard/quick_create_job_base.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_dashboard/quick_create_job_base.ts @@ -69,21 +69,21 @@ export class QuickJobCreatorBase { datafeedConfig, jobConfig, createdByLabel, - dashboard, start, end, startJob, runInRealTime, + dashboard, }: { jobId: string; datafeedConfig: Datafeed; jobConfig: Job; createdByLabel: CREATED_BY_LABEL; - dashboard: Dashboard; start: number | undefined; end: number | undefined; startJob: boolean; runInRealTime: boolean; + dashboard?: Dashboard; }) { const datafeedId = createDatafeedId(jobId); const datafeed = { ...datafeedConfig, job_id: jobId, datafeed_id: datafeedId }; @@ -93,7 +93,7 @@ export class QuickJobCreatorBase { job_id: jobId, custom_settings: { created_by: createdByLabel, - ...(await this.getCustomUrls(dashboard, datafeed)), + ...(dashboard ? await this.getCustomUrls(dashboard, datafeed) : {}), }, }; @@ -230,7 +230,7 @@ export class QuickJobCreatorBase { return mergedQueries; } - protected async createDashboardLink(dashboard: Dashboard, datafeedConfig: estypes.MlDatafeed) { + private async createDashboardLink(dashboard: Dashboard, datafeedConfig: estypes.MlDatafeed) { const dashboardTitle = dashboard?.getTitle(); if (dashboardTitle === undefined || dashboardTitle === '') { // embeddable may have not been in a dashboard @@ -274,7 +274,7 @@ export class QuickJobCreatorBase { return { url_name: urlName, url_value: url, time_range: 'auto' }; } - protected async getCustomUrls(dashboard: Dashboard, datafeedConfig: estypes.MlDatafeed) { + private async getCustomUrls(dashboard: Dashboard, datafeedConfig: estypes.MlDatafeed) { const customUrls = await this.createDashboardLink(dashboard, datafeedConfig); return dashboard !== undefined && customUrls !== null ? { custom_urls: [customUrls] } : {}; } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/quick_create_job.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/quick_create_job.ts index 2ade08c3cf23d4..4df2b74347f47c 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/quick_create_job.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/quick_create_job.ts @@ -135,7 +135,7 @@ export class QuickLensJobCreator extends QuickJobCreatorBase { } } - async createJob( + private async createJob( chartInfo: ChartInfo, startString: string, endString: string, diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/route_resolver.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/route_resolver.ts index 9dcce1facf2989..c8ad1ee6942e49 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/route_resolver.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_lens/route_resolver.ts @@ -15,7 +15,7 @@ import type { DashboardStart } from '@kbn/dashboard-plugin/public'; import { QuickLensJobCreator } from './quick_create_job'; import type { MlApiServices } from '../../../services/ml_api_service'; -import { getDefaultQuery } from '../utils/new_job_utils'; +import { getDefaultQuery, getRisonValue } from '../utils/new_job_utils'; interface Dependencies { lens: LensPublicStart; @@ -27,8 +27,8 @@ interface Dependencies { export async function resolver( deps: Dependencies, lensSavedObjectRisonString: string | undefined, - fromRisonStrong: string, - toRisonStrong: string, + fromRisonString: string, + toRisonString: string, queryRisonString: string, filtersRisonString: string, layerIndexRisonString: string @@ -43,37 +43,11 @@ export async function resolver( throw new Error('Cannot create visualization'); } - let query: Query; - let filters: Filter[]; - try { - query = rison.decode(queryRisonString) as Query; - } catch (error) { - query = getDefaultQuery(); - } - try { - filters = rison.decode(filtersRisonString) as Filter[]; - } catch (error) { - filters = []; - } - - let from: string; - let to: string; - try { - from = rison.decode(fromRisonStrong) as string; - } catch (error) { - from = ''; - } - try { - to = rison.decode(toRisonStrong) as string; - } catch (error) { - to = ''; - } - let layerIndex: number | undefined; - try { - layerIndex = rison.decode(layerIndexRisonString) as number; - } catch (error) { - layerIndex = undefined; - } + const query = getRisonValue(queryRisonString, getDefaultQuery()) as Query; + const filters = getRisonValue(filtersRisonString, []); + const from = getRisonValue(fromRisonString, ''); + const to = getRisonValue(toRisonString, ''); + const layerIndex = getRisonValue(layerIndexRisonString, undefined); const jobCreator = new QuickLensJobCreator( lens, diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_map/quick_create_job.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_map/quick_create_job.ts index e590600cd40470..954abd2d14abce 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_map/quick_create_job.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_map/quick_create_job.ts @@ -162,7 +162,7 @@ export class QuickGeoJobCreator extends QuickJobCreatorBase { } } - async createGeoJob({ + private async createGeoJob({ dataViewId, sourceDataView, from, diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_map/route_resolver.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_map/route_resolver.ts index 39753c77038ca5..0728ca3c61d59e 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_map/route_resolver.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_map/route_resolver.ts @@ -5,16 +5,13 @@ * 2.0. */ -import rison from '@kbn/rison'; -import type { Query } from '@kbn/es-query'; -import type { Filter } from '@kbn/es-query'; import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; import type { TimefilterContract } from '@kbn/data-plugin/public'; import type { DashboardStart } from '@kbn/dashboard-plugin/public'; import type { MlApiServices } from '../../../services/ml_api_service'; import { QuickGeoJobCreator } from './quick_create_job'; -import { getDefaultQuery } from '../utils/new_job_utils'; +import { getDefaultQuery, getRisonValue } from '../utils/new_job_utils'; interface Dependencies { kibanaConfig: IUiSettingsClient; @@ -24,66 +21,32 @@ interface Dependencies { } export async function resolver( deps: Dependencies, - dashboard: string, - dataViewId: string, - embeddable: string, - geoField: string, - splitField: string, + dashboardRisonString: string, + dataViewIdRisonString: string, + embeddableRisonString: string, + geoFieldRisonString: string, + splitFieldRisonString: string, fromRisonString: string, toRisonString: string, - layer?: string + layerRisonString?: string ) { const { kibanaConfig, timeFilter, dashboardService, mlApiServices } = deps; - let decodedDashboard; - let decodedEmbeddable; - let decodedLayer; - let splitFieldDecoded; - let dvId; + const defaultLayer = { query: getDefaultQuery(), filters: [] }; - try { - dvId = rison.decode(dataViewId) as string; - } catch (error) { - dvId = ''; - } + const dashboard = getRisonValue(dashboardRisonString, defaultLayer); + const embeddable = getRisonValue(embeddableRisonString, defaultLayer); - try { - decodedDashboard = rison.decode(dashboard) as { query: Query; filters: Filter[] }; - } catch (error) { - decodedDashboard = { query: getDefaultQuery(), filters: [] }; - } + const layer = + layerRisonString !== undefined + ? getRisonValue(layerRisonString, defaultLayer) + : defaultLayer; - try { - decodedEmbeddable = rison.decode(embeddable) as { query: Query; filters: Filter[] }; - } catch (error) { - decodedEmbeddable = { query: getDefaultQuery(), filters: [] }; - } + const geoField = getRisonValue(geoFieldRisonString, ''); + const splitField = getRisonValue(splitFieldRisonString, null); + const dataViewId = getRisonValue(dataViewIdRisonString, ''); - if (layer) { - try { - decodedLayer = rison.decode(layer) as { query: Query }; - } catch (error) { - decodedLayer = { query: getDefaultQuery(), filters: [] }; - } - } - - try { - splitFieldDecoded = rison.decode(splitField) as string; - } catch (error) { - splitFieldDecoded = null; - } - - let from: string; - let to: string; - try { - from = rison.decode(fromRisonString) as string; - } catch (error) { - from = ''; - } - try { - to = rison.decode(toRisonString) as string; - } catch (error) { - to = ''; - } + const from = getRisonValue(fromRisonString, ''); + const to = getRisonValue(toRisonString, ''); const jobCreator = new QuickGeoJobCreator( kibanaConfig, @@ -93,15 +56,15 @@ export async function resolver( ); await jobCreator.createAndStashGeoJob( - dvId, + dataViewId, from, to, - decodedDashboard.query, - decodedDashboard.filters, - decodedEmbeddable.query, - decodedEmbeddable.filters, + dashboard.query, + dashboard.filters, + embeddable.query, + embeddable.filters, geoField, - splitFieldDecoded, - decodedLayer?.query + splitField, + layer?.query ); } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_pattern_analysis/index.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_pattern_analysis/index.ts new file mode 100644 index 00000000000000..51b3194f28c956 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_pattern_analysis/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { + QuickCategorizationJobCreator, + CATEGORIZATION_TYPE, + type CategorizationType, +} from './quick_create_job'; + +export { resolver } from './route_resolver'; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_pattern_analysis/quick_create_job.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_pattern_analysis/quick_create_job.ts new file mode 100644 index 00000000000000..721d48d1908b6e --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_pattern_analysis/quick_create_job.ts @@ -0,0 +1,209 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IUiSettingsClient } from '@kbn/core/public'; +import type { DataPublicPluginStart, TimefilterContract } from '@kbn/data-plugin/public'; +import type { DashboardStart } from '@kbn/dashboard-plugin/public'; +import { DataViewField, DataView } from '@kbn/data-views-plugin/common'; +import type { TimeRange } from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; +import { MLCATEGORY, ML_JOB_AGGREGATION } from '@kbn/ml-anomaly-utils'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { CREATED_BY_LABEL, DEFAULT_BUCKET_SPAN } from '../../../../../common/constants/new_job'; +import { type CreateState, QuickJobCreatorBase } from '../job_from_dashboard/quick_create_job_base'; +import type { MlApiServices } from '../../../services/ml_api_service'; +import { createEmptyDatafeed, createEmptyJob } from '../common/job_creator/util/default_configs'; +import { stashJobForCloning } from '../common/job_creator/util/general'; +import type { JobCreatorType } from '../common/job_creator'; + +export const CATEGORIZATION_TYPE = { + COUNT: ML_JOB_AGGREGATION.COUNT, + RARE: ML_JOB_AGGREGATION.RARE, +} as const; + +export type CategorizationType = typeof CATEGORIZATION_TYPE[keyof typeof CATEGORIZATION_TYPE]; + +export class QuickCategorizationJobCreator extends QuickJobCreatorBase { + constructor( + kibanaConfig: IUiSettingsClient, + timeFilter: TimefilterContract, + dashboardService: DashboardStart, + private data: DataPublicPluginStart, + mlApiServices: MlApiServices + ) { + super(kibanaConfig, timeFilter, dashboardService, mlApiServices); + } + + public async createAndSaveJob( + categorizationType: CategorizationType, + jobId: string, + bucketSpan: string, + dataView: DataView, + field: DataViewField, + partitionField: DataViewField | null, + stopOnWarn: boolean, + query: QueryDslQueryContainer, + timeRange: TimeRange, + startJob: boolean, + runInRealTime: boolean + ): Promise { + if (query === undefined) { + throw new Error('Cannot create job, query and filters are undefined'); + } + + const { jobConfig, datafeedConfig, start, end } = await this.createJob( + categorizationType, + dataView, + field, + partitionField, + stopOnWarn, + timeRange, + query, + bucketSpan + ); + const createdByLabel = CREATED_BY_LABEL.CATEGORIZATION_FROM_PATTERN_ANALYSIS; + + const result = await this.putJobAndDataFeed({ + jobId, + datafeedConfig, + jobConfig, + createdByLabel, + start, + end, + startJob, + runInRealTime, + }); + return result; + } + + public async createAndStashADJob( + categorizationType: CategorizationType, + dataViewId: string, + fieldName: string, + partitionFieldName: string | null, + stopOnWarn: boolean, + startString: string, + endString: string, + query: QueryDslQueryContainer + ) { + try { + const dataView = await this.data.dataViews.get(dataViewId); + const field = dataView.getFieldByName(fieldName); + const partitionField = partitionFieldName + ? dataView.getFieldByName(partitionFieldName) ?? null + : null; + + if (field === undefined) { + throw new Error('Cannot create job, field is undefined'); + } + + const { jobConfig, datafeedConfig, start, end, includeTimeRange } = await this.createJob( + categorizationType, + dataView, + field, + partitionField, + stopOnWarn, + { from: startString, to: endString }, + query, + DEFAULT_BUCKET_SPAN + ); + + // add job config and start and end dates to the + // job cloning stash, so they can be used + // by the new job wizards + stashJobForCloning( + { + jobConfig, + datafeedConfig, + createdBy: CREATED_BY_LABEL.CATEGORIZATION_FROM_PATTERN_ANALYSIS, + start, + end, + } as JobCreatorType, + true, + includeTimeRange, + !includeTimeRange + ); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } + } + + private async createJob( + categorizationType: CategorizationType, + dataView: DataView, + field: DataViewField, + partitionField: DataViewField | null, + stopOnWarn: boolean, + timeRange: TimeRange, + query: QueryDslQueryContainer, + bucketSpan: string + ) { + const jobConfig = createEmptyJob(); + const datafeedConfig = createEmptyDatafeed(dataView.getIndexPattern()); + + datafeedConfig.query = query; + jobConfig.analysis_config = { + categorization_field_name: field.name, + per_partition_categorization: { + enabled: partitionField !== null, + stop_on_warn: stopOnWarn, + }, + influencers: [MLCATEGORY], + detectors: [ + { + function: categorizationType, + by_field_name: MLCATEGORY, + }, + ], + bucket_span: bucketSpan, + }; + + if (partitionField !== null) { + jobConfig.analysis_config.detectors[0].partition_field_name = partitionField.name; + jobConfig.analysis_config.influencers!.push(partitionField.name); + } + + jobConfig.data_description.time_field = dataView.timeFieldName; + + let start: number | undefined; + let end: number | undefined; + let includeTimeRange = true; + + try { + // attempt to parse the start and end dates. + // if start and end values cannot be determined + // instruct the job cloning code to auto-select the + // full time range for the index. + const { min, max } = this.timeFilter.calculateBounds(timeRange); + start = min?.valueOf(); + end = max?.valueOf(); + + if (start === undefined || end === undefined || isNaN(start) || isNaN(end)) { + throw Error( + i18n.translate('xpack.ml.newJob.fromLens.createJob.error.timeRange', { + defaultMessage: 'Incompatible time range', + }) + ); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + includeTimeRange = false; + start = undefined; + end = undefined; + } + + return { + jobConfig, + datafeedConfig, + start, + end, + includeTimeRange, + }; + } +} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_pattern_analysis/route_resolver.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_pattern_analysis/route_resolver.ts new file mode 100644 index 00000000000000..0f8462128d2cf5 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_pattern_analysis/route_resolver.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; +import type { DataPublicPluginStart, TimefilterContract } from '@kbn/data-plugin/public'; +import type { DashboardStart } from '@kbn/dashboard-plugin/public'; +import type { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types'; +import { + type CategorizationType, + QuickCategorizationJobCreator, + CATEGORIZATION_TYPE, +} from './quick_create_job'; +import type { MlApiServices } from '../../../services/ml_api_service'; + +import { getDefaultDatafeedQuery, getRisonValue } from '../utils/new_job_utils'; + +interface Dependencies { + kibanaConfig: IUiSettingsClient; + timeFilter: TimefilterContract; + dashboardService: DashboardStart; + data: DataPublicPluginStart; + mlApiServices: MlApiServices; +} +export async function resolver( + deps: Dependencies, + categorizationTypeRisonString: string, + dataViewIdRisonString: string, + fieldRisonString: string, + partitionFieldRisonString: string | null, + stopOnWarnRisonString: string, + fromRisonString: string, + toRisonString: string, + queryRisonString: string +) { + const { mlApiServices, timeFilter, kibanaConfig, dashboardService, data } = deps; + + const query = getRisonValue(queryRisonString, getDefaultDatafeedQuery()); + const from = getRisonValue(fromRisonString, ''); + const to = getRisonValue(toRisonString, ''); + const categorizationType = getRisonValue( + categorizationTypeRisonString, + CATEGORIZATION_TYPE.COUNT + ); + const dataViewId = getRisonValue(dataViewIdRisonString, ''); + const field = getRisonValue(fieldRisonString, ''); + const partitionField = + partitionFieldRisonString === null ? '' : getRisonValue(partitionFieldRisonString, ''); + const stopOnWarn = getRisonValue(stopOnWarnRisonString, false); + + const jobCreator = new QuickCategorizationJobCreator( + kibanaConfig, + timeFilter, + dashboardService, + data, + mlApiServices + ); + await jobCreator.createAndStashADJob( + categorizationType, + dataViewId, + field, + partitionField, + stopOnWarn, + from, + to, + query + ); +} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/job_from_pattern_analysis/utils.ts b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_pattern_analysis/utils.ts new file mode 100644 index 00000000000000..4bc84a4df20dc7 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/job_from_pattern_analysis/utils.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DataViewField, DataView } from '@kbn/data-views-plugin/common'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { TimeRange } from '@kbn/es-query'; +import { ML_APP_LOCATOR } from '../../../../../common/constants/locator'; +import { ML_PAGES } from '../../../../locator'; +import type { CategorizationType } from './quick_create_job'; + +export async function redirectToADJobWizards( + categorizationType: CategorizationType, + dataView: DataView, + field: DataViewField, + partitionField: DataViewField | null, + stopOnWarn: boolean, + query: QueryDslQueryContainer, + timeRange: TimeRange, + share: SharePluginStart +) { + const locator = share.url.locators.get(ML_APP_LOCATOR)!; + + const url = await locator.getUrl({ + page: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_FROM_PATTERN_ANALYSIS, + pageState: { + categorizationType, + dataViewId: dataView.id, + field: field.name, + partitionField: partitionField?.name || null, + stopOnWarn, + from: timeRange.from, + to: timeRange.to, + query: JSON.stringify(query), + }, + }); + + window.open(url, '_blank'); +} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts b/x-pack/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts index b13cde5e94ccdc..ba31ff5e4cd942 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts @@ -55,6 +55,7 @@ async function getWizardUrlFromCloningJob(createdBy: string | undefined, dataVie page = JOB_TYPE.POPULATION; break; case CREATED_BY_LABEL.CATEGORIZATION: + case CREATED_BY_LABEL.CATEGORIZATION_FROM_PATTERN_ANALYSIS: page = JOB_TYPE.CATEGORIZATION; break; case CREATED_BY_LABEL.RARE: diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts index 32fd17c9d1f109..eef866da2d287b 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts @@ -7,6 +7,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { cloneDeep } from 'lodash'; +import rison from '@kbn/rison'; import { Query, fromKueryExpression, @@ -162,3 +163,14 @@ export function checkCardinalitySuccess(data: any) { return response; } + +export function getRisonValue( + risonString: string, + defaultValue: T +) { + try { + return rison.decode(risonString) as T; + } catch (error) { + return defaultValue; + } +} diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/from_pattern_analysis.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/from_pattern_analysis.tsx new file mode 100644 index 00000000000000..1ac93184f201ed --- /dev/null +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/from_pattern_analysis.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { Redirect } from 'react-router-dom'; +import { parse } from 'query-string'; +import { useMlKibana } from '../../../contexts/kibana'; +import { ML_PAGES } from '../../../../locator'; +import { createPath, MlRoute, PageLoader, PageProps } from '../../router'; +import { useRouteResolver } from '../../use_resolver'; +import { resolver } from '../../../jobs/new_job/job_from_pattern_analysis'; + +export const fromPatternAnalysisRouteFactory = (): MlRoute => ({ + path: createPath(ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_FROM_PATTERN_ANALYSIS), + render: (props, deps) => , + breadcrumbs: [], +}); + +const PageWrapper: FC = ({ location }) => { + const { + categorizationType, + dataViewId, + field, + partitionField, + stopOnWarn, + from, + to, + query, + }: Record = parse(location.search, { + sort: false, + }); + const { + services: { + data, + dashboard: dashboardService, + uiSettings: kibanaConfig, + mlServices: { mlApiServices }, + }, + } = useMlKibana(); + + const { context } = useRouteResolver('full', ['canCreateJob'], { + redirect: () => + resolver( + { + mlApiServices, + timeFilter: data.query.timefilter.timefilter, + kibanaConfig, + dashboardService, + data, + }, + categorizationType, + dataViewId, + field, + partitionField, + stopOnWarn, + from, + to, + query + ), + }); + + return ( + + {} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/index.ts b/x-pack/plugins/ml/public/application/routing/routes/new_job/index.ts index 675b391d1e8263..d4876aba2444e1 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/index.ts +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/index.ts @@ -12,3 +12,4 @@ export * from './wizard'; export * from './recognize'; export * from './from_lens'; export * from './from_map'; +export * from './from_pattern_analysis'; diff --git a/x-pack/plugins/ml/public/application/services/new_job_capabilities/new_job_capabilities_service.ts b/x-pack/plugins/ml/public/application/services/new_job_capabilities/new_job_capabilities_service.ts index f073c4afea8440..511a1a1457b02b 100644 --- a/x-pack/plugins/ml/public/application/services/new_job_capabilities/new_job_capabilities_service.ts +++ b/x-pack/plugins/ml/public/application/services/new_job_capabilities/new_job_capabilities_service.ts @@ -14,15 +14,21 @@ import { EVENT_RATE_FIELD_ID, } from '@kbn/ml-anomaly-utils'; import { getGeoFields, filterCategoryFields } from '../../../../common/util/fields_utils'; -import { ml } from '../ml_api_service'; +import { ml, type MlApiServices } from '../ml_api_service'; import { processTextAndKeywordFields, NewJobCapabilitiesServiceBase } from './new_job_capabilities'; -class NewJobCapsService extends NewJobCapabilitiesServiceBase { +export class NewJobCapsService extends NewJobCapabilitiesServiceBase { private _catFields: Field[] = []; private _dateFields: Field[] = []; private _geoFields: Field[] = []; private _includeEventRateField: boolean = true; private _removeTextFields: boolean = true; + private _mlApiService: MlApiServices; + + constructor(mlApiService: MlApiServices) { + super(); + this._mlApiService = mlApiService; + } public get catFields(): Field[] { return this._catFields; @@ -49,7 +55,10 @@ class NewJobCapsService extends NewJobCapabilitiesServiceBase { this._includeEventRateField = includeEventRateField; this._removeTextFields = removeTextFields; - const resp = await ml.jobs.newJobCaps(dataView.getIndexPattern(), dataView.type === 'rollup'); + const resp = await this._mlApiService.jobs.newJobCaps( + dataView.getIndexPattern(), + dataView.type === 'rollup' + ); const { fields: allFields, aggs } = createObjects(resp, dataView.getIndexPattern()); if (this._includeEventRateField === true) { @@ -175,4 +184,4 @@ function addEventRateField(aggs: Aggregation[], fields: Field[]) { fields.splice(0, 0, eventRateField); } -export const newJobCapsService = new NewJobCapsService(); +export const newJobCapsService = new NewJobCapsService(ml); diff --git a/x-pack/plugins/ml/public/embeddables/job_creation/aiops/flyout/create_job.tsx b/x-pack/plugins/ml/public/embeddables/job_creation/aiops/flyout/create_job.tsx new file mode 100644 index 00000000000000..5e52b0f5cd2129 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/job_creation/aiops/flyout/create_job.tsx @@ -0,0 +1,273 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useCallback, useMemo, useState, useEffect } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { + EuiCheckableCard, + EuiTitle, + EuiSpacer, + EuiSwitch, + EuiHorizontalRule, + EuiComboBoxOptionOption, + EuiComboBox, + EuiFormRow, + EuiCallOut, +} from '@elastic/eui'; + +import type { DataViewField, DataView } from '@kbn/data-views-plugin/common'; +import type { TimeRange } from '@kbn/es-query'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { redirectToADJobWizards } from '../../../../application/jobs/new_job/job_from_pattern_analysis/utils'; +import { createFieldOptions } from '../../../../application/jobs/new_job/common/job_creator/util/general'; +import { NewJobCapsService } from '../../../../application/services/new_job_capabilities/new_job_capabilities_service'; +import { + type CategorizationType, + CATEGORIZATION_TYPE, + QuickCategorizationJobCreator, +} from '../../../../application/jobs/new_job/job_from_pattern_analysis'; +import { useMlFromLensKibanaContext } from '../../common/context'; +import { JobDetails, type CreateADJobParams } from '../../common/job_details'; + +interface Props { + dataView: DataView; + field: DataViewField; + query: QueryDslQueryContainer; + timeRange: TimeRange; +} + +export const CreateJob: FC = ({ dataView, field, query, timeRange }) => { + const { + services: { + data, + share, + uiSettings, + mlServices: { mlApiServices }, + dashboardService, + }, + } = useMlFromLensKibanaContext(); + + const [categorizationType, setCategorizationType] = useState( + CATEGORIZATION_TYPE.COUNT + ); + const [enablePerPartitionCategorization, setEnablePerPartitionCategorization] = useState(false); + const [stopOnWarn, setStopOnWarn] = useState(false); + const [categoryFieldOptions, setCategoryFieldsOptions] = useState([]); + const [selectedPartitionFieldOptions, setSelectedPartitionFieldOptions] = useState< + EuiComboBoxOptionOption[] + >([]); + const [formComplete, setFormComplete] = useState(undefined); + + const toggleEnablePerPartitionCategorization = useCallback( + () => setEnablePerPartitionCategorization(!enablePerPartitionCategorization), + [enablePerPartitionCategorization] + ); + + const toggleStopOnWarn = useCallback(() => setStopOnWarn(!stopOnWarn), [stopOnWarn]); + + useMemo(() => { + const newJobCapsService = new NewJobCapsService(mlApiServices); + newJobCapsService.initializeFromDataVIew(dataView).then(() => { + const options: EuiComboBoxOptionOption[] = [ + ...createFieldOptions(newJobCapsService.categoryFields, []), + ].map((o) => ({ + ...o, + })); + setCategoryFieldsOptions(options); + }); + }, [dataView, mlApiServices]); + + const quickJobCreator = useMemo( + () => + new QuickCategorizationJobCreator( + uiSettings, + data.query.timefilter.timefilter, + dashboardService, + data, + mlApiServices + ), + + [dashboardService, data, mlApiServices, uiSettings] + ); + + function createADJobInWizard() { + const partitionField = selectedPartitionFieldOptions.length + ? dataView.getFieldByName(selectedPartitionFieldOptions[0].label) ?? null + : null; + redirectToADJobWizards( + categorizationType, + dataView, + field, + partitionField, + stopOnWarn, + query, + timeRange, + share + ); + } + + useEffect(() => { + setSelectedPartitionFieldOptions([]); + setStopOnWarn(false); + }, [enablePerPartitionCategorization]); + + useEffect(() => { + setFormComplete( + enablePerPartitionCategorization === false || selectedPartitionFieldOptions.length > 0 + ); + }, [enablePerPartitionCategorization, selectedPartitionFieldOptions]); + + async function createADJob({ jobId, bucketSpan, startJob, runInRealTime }: CreateADJobParams) { + const partitionField = selectedPartitionFieldOptions.length + ? dataView.getFieldByName(selectedPartitionFieldOptions[0].label) ?? null + : null; + const result = await quickJobCreator.createAndSaveJob( + categorizationType, + jobId, + bucketSpan, + dataView, + field, + partitionField, + stopOnWarn, + query, + timeRange, + startJob, + runInRealTime + ); + return result; + } + return ( + + <> + + +
+ +
+
+ + + + } + checked={categorizationType === CATEGORIZATION_TYPE.COUNT} + onChange={() => setCategorizationType(CATEGORIZATION_TYPE.COUNT)} + /> + + + + + +
+ +
+
+ + + + } + checked={categorizationType === CATEGORIZATION_TYPE.RARE} + onChange={() => setCategorizationType(CATEGORIZATION_TYPE.RARE)} + /> + + + } + /> + + {enablePerPartitionCategorization ? ( + <> + + + + } + /> + + + + + } + > + + + + + + + } + /> + + ) : null} + + + + + +
+ ); +}; diff --git a/x-pack/plugins/ml/public/embeddables/job_creation/aiops/flyout/flyout.tsx b/x-pack/plugins/ml/public/embeddables/job_creation/aiops/flyout/flyout.tsx new file mode 100644 index 00000000000000..48781b17761419 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/job_creation/aiops/flyout/flyout.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiFlyoutBody, + EuiTitle, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +import type { DataViewField, DataView } from '@kbn/data-views-plugin/common'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { TimeRange } from '@kbn/es-query'; +import { CreateJob } from './create_job'; + +interface Props { + dataView: DataView; + field: DataViewField; + query: QueryDslQueryContainer; + timeRange: TimeRange; + onClose: () => void; +} + +export const CreateCategorizationJobFlyout: FC = ({ + onClose, + dataView, + field, + query, + timeRange, +}) => { + return ( + <> + + +

+ +

+
+ + + + +
+ + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/embeddables/job_creation/aiops/flyout/index.ts b/x-pack/plugins/ml/public/embeddables/job_creation/aiops/flyout/index.ts new file mode 100644 index 00000000000000..ab544dc3d55bd7 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/job_creation/aiops/flyout/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { CreateCategorizationJobFlyout } from './flyout'; diff --git a/x-pack/plugins/ml/public/embeddables/job_creation/aiops/index.ts b/x-pack/plugins/ml/public/embeddables/job_creation/aiops/index.ts new file mode 100644 index 00000000000000..a156caa5ef57a8 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/job_creation/aiops/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { showPatternAnalysisToADJobFlyout } from './show_flyout'; diff --git a/x-pack/plugins/ml/public/embeddables/job_creation/aiops/show_flyout.tsx b/x-pack/plugins/ml/public/embeddables/job_creation/aiops/show_flyout.tsx new file mode 100644 index 00000000000000..c1fe261cbe9277 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/job_creation/aiops/show_flyout.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import type { CoreStart } from '@kbn/core/public'; +import type { SharePluginStart } from '@kbn/share-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; +import type { DashboardStart } from '@kbn/dashboard-plugin/public'; +import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { TimeRange } from '@kbn/es-query'; +import { createFlyout, type FlyoutComponentProps } from '../common/create_flyout'; +import { CreateCategorizationJobFlyout } from './flyout'; + +export async function showPatternAnalysisToADJobFlyout( + dataView: DataView, + field: DataViewField, + query: QueryDslQueryContainer, + timeRange: TimeRange, + coreStart: CoreStart, + share: SharePluginStart, + data: DataPublicPluginStart, + dashboardService: DashboardStart, + lens?: LensPublicStart +): Promise { + const Comp: FC = ({ onClose }) => ( + + ); + return createFlyout(Comp, coreStart, share, data, dashboardService, lens); +} diff --git a/x-pack/plugins/ml/public/embeddables/job_creation/lens/context.ts b/x-pack/plugins/ml/public/embeddables/job_creation/common/context.ts similarity index 100% rename from x-pack/plugins/ml/public/embeddables/job_creation/lens/context.ts rename to x-pack/plugins/ml/public/embeddables/job_creation/common/context.ts diff --git a/x-pack/plugins/ml/public/embeddables/job_creation/common/create_flyout.tsx b/x-pack/plugins/ml/public/embeddables/job_creation/common/create_flyout.tsx index 7fd5f9e86fca8c..b1bc9ec47ba941 100644 --- a/x-pack/plugins/ml/public/embeddables/job_creation/common/create_flyout.tsx +++ b/x-pack/plugins/ml/public/embeddables/job_creation/common/create_flyout.tsx @@ -14,15 +14,16 @@ import type { SharePluginStart } from '@kbn/share-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { CoreStart } from '@kbn/core/public'; import type { LensPublicStart } from '@kbn/lens-plugin/public'; -import type { MapEmbeddable } from '@kbn/maps-plugin/public'; -import type { Embeddable } from '@kbn/lens-plugin/public'; import type { DashboardStart } from '@kbn/dashboard-plugin/public'; import { getMlGlobalServices } from '../../../application/app'; +export interface FlyoutComponentProps { + onClose: () => void; +} + export function createFlyout( FlyoutComponent: React.FunctionComponent, - embeddable: MapEmbeddable | Embeddable, coreStart: CoreStart, share: SharePluginStart, data: DataPublicPluginStart, @@ -57,7 +58,6 @@ export function createFlyout( }} > { onFlyoutClose(); resolve(); diff --git a/x-pack/plugins/ml/public/embeddables/job_creation/common/job_details.tsx b/x-pack/plugins/ml/public/embeddables/job_creation/common/job_details.tsx index e2fff3bd286cf3..312d75a2a97b7c 100644 --- a/x-pack/plugins/ml/public/embeddables/job_creation/common/job_details.tsx +++ b/x-pack/plugins/ml/public/embeddables/job_creation/common/job_details.tsx @@ -32,6 +32,7 @@ import type { Embeddable } from '@kbn/lens-plugin/public'; import type { MapEmbeddable } from '@kbn/maps-plugin/public'; import { extractErrorMessage } from '@kbn/ml-error-utils'; +import type { TimeRange } from '@kbn/es-query'; import { QuickLensJobCreator } from '../../../application/jobs/new_job/job_from_lens'; import type { LayerResult } from '../../../application/jobs/new_job/job_from_lens'; import type { CreateState } from '../../../application/jobs/new_job/job_from_dashboard'; @@ -40,12 +41,12 @@ import { basicJobValidation } from '../../../../common/util/job_utils'; import { JOB_ID_MAX_LENGTH } from '../../../../common/constants/validation'; import { invalidTimeIntervalMessage } from '../../../application/jobs/new_job/common/job_validator/util'; import { ML_APP_LOCATOR, ML_PAGES } from '../../../../common/constants/locator'; -import { useMlFromLensKibanaContext } from '../lens/context'; +import { useMlFromLensKibanaContext } from './context'; export interface CreateADJobParams { jobId: string; bucketSpan: string; - embeddable: MapEmbeddable | Embeddable; + embeddable: MapEmbeddable | Embeddable | undefined; startJob: boolean; runInRealTime: boolean; } @@ -56,8 +57,10 @@ interface Props { createADJob: (args: CreateADJobParams) => Promise; layer?: LayerResult; layerIndex: number; - embeddable: Embeddable | MapEmbeddable; + embeddable: Embeddable | MapEmbeddable | undefined; + timeRange: TimeRange | undefined; incomingCreateError?: { text: string; errorText: string }; + outerFormComplete?: boolean; } enum STATE { @@ -75,7 +78,9 @@ export const JobDetails: FC = ({ layer, layerIndex, embeddable, + timeRange, incomingCreateError, + outerFormComplete, }) => { const { services: { @@ -121,7 +126,6 @@ export const JobDetails: FC = ({ const viewResults = useCallback( async (type: JOB_TYPE | null) => { - const { timeRange } = embeddable.getInput(); const locator = share.url.locators.get(ML_APP_LOCATOR); if (locator) { const page = startJob @@ -144,7 +148,7 @@ export const JobDetails: FC = ({ application.navigateToUrl(url); } }, - [jobId, embeddable, share, application, startJob] + [share, startJob, jobId, timeRange, application] ); function setStartJobWrapper(start: boolean) { @@ -313,7 +317,8 @@ export const JobDetails: FC = ({ state === STATE.VALIDATING || jobId === '' || jobIdValidationError !== '' || - bucketSpanValidationError !== '' + bucketSpanValidationError !== '' || + outerFormComplete === false } onClick={createJob.bind(null, layerIndex)} size="s" diff --git a/x-pack/plugins/ml/public/embeddables/job_creation/lens/lens_vis_layer_selection_flyout/flyout.tsx b/x-pack/plugins/ml/public/embeddables/job_creation/lens/lens_vis_layer_selection_flyout/flyout.tsx index 06420c071c2209..dc0ab2edd4a1eb 100644 --- a/x-pack/plugins/ml/public/embeddables/job_creation/lens/lens_vis_layer_selection_flyout/flyout.tsx +++ b/x-pack/plugins/ml/public/embeddables/job_creation/lens/lens_vis_layer_selection_flyout/flyout.tsx @@ -23,7 +23,7 @@ import { import { Layer } from './layer'; import type { LayerResult } from '../../../../application/jobs/new_job/job_from_lens'; import { VisualizationExtractor } from '../../../../application/jobs/new_job/job_from_lens'; -import { useMlFromLensKibanaContext } from '../context'; +import { useMlFromLensKibanaContext } from '../../common/context'; interface Props { embeddable: Embeddable; diff --git a/x-pack/plugins/ml/public/embeddables/job_creation/lens/lens_vis_layer_selection_flyout/layer/compatible_layer.tsx b/x-pack/plugins/ml/public/embeddables/job_creation/lens/lens_vis_layer_selection_flyout/layer/compatible_layer.tsx index 0d024bd2d77af3..d82ddbf94dd86a 100644 --- a/x-pack/plugins/ml/public/embeddables/job_creation/lens/lens_vis_layer_selection_flyout/layer/compatible_layer.tsx +++ b/x-pack/plugins/ml/public/embeddables/job_creation/lens/lens_vis_layer_selection_flyout/layer/compatible_layer.tsx @@ -17,7 +17,7 @@ import { } from '../../../../../application/jobs/new_job/job_from_lens'; import type { LayerResult } from '../../../../../application/jobs/new_job/job_from_lens'; import { JOB_TYPE } from '../../../../../../common/constants/new_job'; -import { useMlFromLensKibanaContext } from '../../context'; +import { useMlFromLensKibanaContext } from '../../../common/context'; import { JobDetails, CreateADJobParams } from '../../../common/job_details'; interface Props { @@ -79,6 +79,7 @@ export const CompatibleLayer: FC = ({ layer, layerIndex, embeddable }) => createADJob={createADJob} createADJobInWizard={createADJobInWizard} embeddable={embeddable} + timeRange={embeddable.getInput().timeRange} layer={layer} layerIndex={layerIndex} > diff --git a/x-pack/plugins/ml/public/embeddables/job_creation/lens/show_flyout.tsx b/x-pack/plugins/ml/public/embeddables/job_creation/lens/show_flyout.tsx index 91e6f6f0018617..375588765bd189 100644 --- a/x-pack/plugins/ml/public/embeddables/job_creation/lens/show_flyout.tsx +++ b/x-pack/plugins/ml/public/embeddables/job_creation/lens/show_flyout.tsx @@ -5,13 +5,14 @@ * 2.0. */ +import React, { FC } from 'react'; import type { Embeddable } from '@kbn/lens-plugin/public'; import type { CoreStart } from '@kbn/core/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { LensPublicStart } from '@kbn/lens-plugin/public'; import type { DashboardStart } from '@kbn/dashboard-plugin/public'; -import { createFlyout } from '../common/create_flyout'; +import { createFlyout, type FlyoutComponentProps } from '../common/create_flyout'; import { LensLayerSelectionFlyout } from './lens_vis_layer_selection_flyout'; export async function showLensVisToADJobFlyout( @@ -19,16 +20,11 @@ export async function showLensVisToADJobFlyout( coreStart: CoreStart, share: SharePluginStart, data: DataPublicPluginStart, - lens: LensPublicStart, - dashboardService: DashboardStart + dashboardService: DashboardStart, + lens: LensPublicStart ): Promise { - return createFlyout( - LensLayerSelectionFlyout, - embeddable, - coreStart, - share, - data, - dashboardService, - lens + const Comp: FC = ({ onClose }) => ( + ); + return createFlyout(Comp, coreStart, share, data, dashboardService, lens); } diff --git a/x-pack/plugins/ml/public/embeddables/job_creation/map/map_vis_layer_selection_flyout/layer/compatible_layer.tsx b/x-pack/plugins/ml/public/embeddables/job_creation/map/map_vis_layer_selection_flyout/layer/compatible_layer.tsx index d4075414e3a940..8f368dc0a82c03 100644 --- a/x-pack/plugins/ml/public/embeddables/job_creation/map/map_vis_layer_selection_flyout/layer/compatible_layer.tsx +++ b/x-pack/plugins/ml/public/embeddables/job_creation/map/map_vis_layer_selection_flyout/layer/compatible_layer.tsx @@ -25,7 +25,7 @@ import { QuickGeoJobCreator, redirectToGeoJobWizard, } from '../../../../../application/jobs/new_job/job_from_map'; -import { useMlFromLensKibanaContext } from '../../../lens/context'; +import { useMlFromLensKibanaContext } from '../../../common/context'; import { JobDetails, CreateADJobParams } from '../../../common/job_details'; interface DropDownLabel { @@ -147,6 +147,7 @@ export const CompatibleLayer: FC = ({ embeddable, layer, layerIndex }) => createADJob={createGeoJob} createADJobInWizard={createGeoJobInWizard} embeddable={embeddable} + timeRange={embeddable.getInput().timeRange} incomingCreateError={createError} > <> diff --git a/x-pack/plugins/ml/public/embeddables/job_creation/map/show_flyout.tsx b/x-pack/plugins/ml/public/embeddables/job_creation/map/show_flyout.tsx index 5380513f1dc97e..293ec69b30dbe5 100644 --- a/x-pack/plugins/ml/public/embeddables/job_creation/map/show_flyout.tsx +++ b/x-pack/plugins/ml/public/embeddables/job_creation/map/show_flyout.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import React, { FC } from 'react'; import type { CoreStart } from '@kbn/core/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; @@ -12,7 +13,7 @@ import type { MapEmbeddable } from '@kbn/maps-plugin/public'; import type { DashboardStart } from '@kbn/dashboard-plugin/public'; import { GeoJobFlyout } from './flyout'; -import { createFlyout } from '../common/create_flyout'; +import { createFlyout, type FlyoutComponentProps } from '../common/create_flyout'; export async function showMapVisToADJobFlyout( embeddable: MapEmbeddable, @@ -21,5 +22,8 @@ export async function showMapVisToADJobFlyout( data: DataPublicPluginStart, dashboardService: DashboardStart ): Promise { - return createFlyout(GeoJobFlyout, embeddable, coreStart, share, data, dashboardService); + const Comp: FC = ({ onClose }) => ( + + ); + return createFlyout(Comp, coreStart, share, data, dashboardService); } diff --git a/x-pack/plugins/ml/public/locator/ml_locator.ts b/x-pack/plugins/ml/public/locator/ml_locator.ts index e397778315a6a0..05fe312fd9a46b 100644 --- a/x-pack/plugins/ml/public/locator/ml_locator.ts +++ b/x-pack/plugins/ml/public/locator/ml_locator.ts @@ -85,6 +85,7 @@ export class MlLocatorDefinition implements LocatorDefinition { case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_ADVANCED: case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_FROM_LENS: case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_FROM_MAP: + case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_FROM_PATTERN_ANALYSIS: case ML_PAGES.DATA_VISUALIZER: case ML_PAGES.DATA_VISUALIZER_FILE: case ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER: diff --git a/x-pack/plugins/ml/public/ui_actions/index.ts b/x-pack/plugins/ml/public/ui_actions/index.ts index 4067547e089563..4e756d9d44d50f 100644 --- a/x-pack/plugins/ml/public/ui_actions/index.ts +++ b/x-pack/plugins/ml/public/ui_actions/index.ts @@ -8,9 +8,14 @@ import { CoreSetup } from '@kbn/core/public'; import { UiActionsSetup } from '@kbn/ui-actions-plugin/public'; import { CONTEXT_MENU_TRIGGER } from '@kbn/embeddable-plugin/public'; +import { CREATE_PATTERN_ANALYSIS_TO_ML_AD_JOB_TRIGGER } from '@kbn/ml-ui-actions'; import { createEditSwimlanePanelAction } from './edit_swimlane_panel_action'; import { createOpenInExplorerAction } from './open_in_anomaly_explorer_action'; import { createVisToADJobAction } from './open_vis_in_ml_action'; +import { + createCategorizationADJobAction, + createCategorizationADJobTrigger, +} from './open_create_categorization_job_action'; import { MlPluginStart, MlStartDependencies } from '../plugin'; import { createApplyInfluencerFiltersAction } from './apply_influencer_filters_action'; import { @@ -45,6 +50,7 @@ export function registerMlUiActions( const clearSelectionAction = createClearSelectionAction(core.getStartServices); const editExplorerPanelAction = createEditAnomalyChartsPanelAction(core.getStartServices); const visToAdJobAction = createVisToADJobAction(core.getStartServices); + const categorizationADJobAction = createCategorizationADJobAction(core.getStartServices); // Register actions uiActions.registerAction(editSwimlanePanelAction); @@ -54,6 +60,7 @@ export function registerMlUiActions( uiActions.registerAction(applyTimeRangeSelectionAction); uiActions.registerAction(clearSelectionAction); uiActions.registerAction(editExplorerPanelAction); + uiActions.registerAction(categorizationADJobAction); // Assign triggers uiActions.attachAction(CONTEXT_MENU_TRIGGER, editSwimlanePanelAction.id); @@ -62,6 +69,7 @@ export function registerMlUiActions( uiActions.registerTrigger(swimLaneSelectionTrigger); uiActions.registerTrigger(entityFieldSelectionTrigger); + uiActions.registerTrigger(createCategorizationADJobTrigger); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, applyInfluencerFiltersAction); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, applyTimeRangeSelectionAction); @@ -69,4 +77,8 @@ export function registerMlUiActions( uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, clearSelectionAction); uiActions.addTriggerAction(EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER, applyEntityFieldFilterAction); uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, visToAdJobAction); + uiActions.addTriggerAction( + CREATE_PATTERN_ANALYSIS_TO_ML_AD_JOB_TRIGGER, + categorizationADJobAction + ); } diff --git a/x-pack/plugins/ml/public/ui_actions/open_create_categorization_job_action.tsx b/x-pack/plugins/ml/public/ui_actions/open_create_categorization_job_action.tsx new file mode 100644 index 00000000000000..2855020a201dff --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/open_create_categorization_job_action.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { Trigger, UiActionsActionDefinition } from '@kbn/ui-actions-plugin/public'; +import { + CREATE_PATTERN_ANALYSIS_TO_ML_AD_JOB_ACTION, + CREATE_PATTERN_ANALYSIS_TO_ML_AD_JOB_TRIGGER, + type CreateCategorizationADJobContext, +} from '@kbn/ml-ui-actions'; +import type { MlCoreSetup } from '../plugin'; + +export const createCategorizationADJobTrigger: Trigger = { + id: CREATE_PATTERN_ANALYSIS_TO_ML_AD_JOB_TRIGGER, + title: i18n.translate('xpack.ml.actions.createADJobFromPatternAnalysis', { + defaultMessage: 'Create categorization anomaly detection job', + }), + description: i18n.translate('xpack.ml.actions.createADJobFromPatternAnalysis', { + defaultMessage: 'Create categorization anomaly detection job', + }), +}; + +export function createCategorizationADJobAction( + getStartServices: MlCoreSetup['getStartServices'] +): UiActionsActionDefinition { + return { + id: 'create-ml-categorization-ad-job-action', + type: CREATE_PATTERN_ANALYSIS_TO_ML_AD_JOB_ACTION, + getIconType(context): string { + return 'machineLearningApp'; + }, + getDisplayName: () => + i18n.translate('xpack.ml.actions.createADJobFromPatternAnalysis', { + defaultMessage: 'Create categorization anomaly detection job', + }), + async execute({ dataView, field, query, timeRange }: CreateCategorizationADJobContext) { + if (!dataView) { + throw new Error('Not possible to execute an action without the embeddable context'); + } + + try { + const [{ showPatternAnalysisToADJobFlyout }, [coreStart, { share, data, dashboard }]] = + await Promise.all([import('../embeddables/job_creation/aiops'), getStartServices()]); + + await showPatternAnalysisToADJobFlyout( + dataView, + field, + query, + timeRange, + coreStart, + share, + data, + dashboard + ); + } catch (e) { + return Promise.reject(); + } + }, + async isCompatible({ dataView, field }: CreateCategorizationADJobContext) { + return ( + dataView.timeFieldName !== undefined && + dataView.fields.find((f) => f.name === field.name) !== undefined + ); + }, + }; +} diff --git a/x-pack/plugins/ml/public/ui_actions/open_vis_in_ml_action.tsx b/x-pack/plugins/ml/public/ui_actions/open_vis_in_ml_action.tsx index fb0aa38e44d90a..f47df760ea9cdc 100644 --- a/x-pack/plugins/ml/public/ui_actions/open_vis_in_ml_action.tsx +++ b/x-pack/plugins/ml/public/ui_actions/open_vis_in_ml_action.tsx @@ -39,7 +39,7 @@ export function createVisToADJobAction( if (lens === undefined) { return; } - await showLensVisToADJobFlyout(embeddable, coreStart, share, data, lens, dashboard); + await showLensVisToADJobFlyout(embeddable, coreStart, share, data, dashboard, lens); } else if (isMapEmbeddable(embeddable)) { const [{ showMapVisToADJobFlyout }, [coreStart, { share, data, dashboard }]] = await Promise.all([import('../embeddables/job_creation/map'), getStartServices()]); diff --git a/x-pack/plugins/ml/tsconfig.json b/x-pack/plugins/ml/tsconfig.json index cca832eee04237..846825569da661 100644 --- a/x-pack/plugins/ml/tsconfig.json +++ b/x-pack/plugins/ml/tsconfig.json @@ -96,6 +96,7 @@ "@kbn/ml-runtime-field-utils", "@kbn/ml-date-utils", "@kbn/ml-category-validator", + "@kbn/ml-ui-actions", "@kbn/deeplinks-ml", "@kbn/core-notifications-browser-mocks", "@kbn/unified-field-list", diff --git a/yarn.lock b/yarn.lock index f9b51eea5202c4..a3a29ac19c0701 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5069,6 +5069,10 @@ version "0.0.0" uid "" +"@kbn/ml-ui-actions@link:x-pack/packages/ml/ui_actions": + version "0.0.0" + uid "" + "@kbn/ml-url-state@link:x-pack/packages/ml/url_state": version "0.0.0" uid "" From 0bc8d3872e6b1e58cf2c4c0cde9968c751880429 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 21 Nov 2023 15:53:04 +0000 Subject: [PATCH 07/28] skip flaky suite (#171643) --- .../serverless/endpoint_list_with_security_essentials.cy.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/endpoint_list_with_security_essentials.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/endpoint_list_with_security_essentials.cy.ts index 9cd298535df26d..a28d710091eb47 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/endpoint_list_with_security_essentials.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/endpoint_list_with_security_essentials.cy.ts @@ -15,7 +15,8 @@ import { visitEndpointList, } from '../../screens'; -describe( +// FLAKY: https://github.com/elastic/kibana/issues/171643 +describe.skip( 'When on the Endpoint List in Security Essentials PLI', { tags: ['@serverless'], From 09a389f805dd1a1dbc1b2752d9104d3043cc67b2 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 21 Nov 2023 15:55:56 +0000 Subject: [PATCH 08/28] skip flaky suite (#171644) --- .../e2e/artifacts/artifact_tabs_in_policy_details.cy.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifact_tabs_in_policy_details.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifact_tabs_in_policy_details.cy.ts index b6040691c485f4..4a80c693beb59a 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifact_tabs_in_policy_details.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifact_tabs_in_policy_details.cy.ts @@ -59,7 +59,8 @@ const visitArtifactTab = (tabId: string) => { cy.get(`#${tabId}`).click(); }; -describe('Artifact tabs in Policy Details page', { tags: ['@ess', '@serverless'] }, () => { +// FLAKY: https://github.com/elastic/kibana/issues/171644 +describe.skip('Artifact tabs in Policy Details page', { tags: ['@ess', '@serverless'] }, () => { let endpointData: ReturnTypeFromChainable | undefined; before(() => { From 663c5f611ff8b47b4bf5c2d0d4c87801da479eeb Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 21 Nov 2023 15:57:57 +0000 Subject: [PATCH 09/28] skip flaky suite (#171653) --- .../apps/integrations/policy_details.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/security_solution_endpoint/apps/integrations/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/integrations/policy_details.ts index b48c64b5e9dd4a..56eca6cde0f01e 100644 --- a/x-pack/test/security_solution_endpoint/apps/integrations/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/integrations/policy_details.ts @@ -28,7 +28,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const endpointTestResources = getService('endpointTestResources'); const retry = getService('retry'); - describe('When on the Endpoint Policy Details Page', function () { + // FLAKY: https://github.com/elastic/kibana/issues/171653 + describe.skip('When on the Endpoint Policy Details Page', function () { targetTags(this, ['@ess', '@serverless']); let indexedData: IndexedHostsAndAlertsResponse; From c382428a19ca2351e874b6b6664132602d212e72 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 21 Nov 2023 15:58:55 +0000 Subject: [PATCH 10/28] skip flaky suite (#171654) --- .../apps/integrations/policy_details.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/test/security_solution_endpoint/apps/integrations/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/integrations/policy_details.ts index 56eca6cde0f01e..a2b1feaabd7cd3 100644 --- a/x-pack/test/security_solution_endpoint/apps/integrations/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/integrations/policy_details.ts @@ -29,6 +29,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const retry = getService('retry'); // FLAKY: https://github.com/elastic/kibana/issues/171653 + // FLAKY: https://github.com/elastic/kibana/issues/171654 describe.skip('When on the Endpoint Policy Details Page', function () { targetTags(this, ['@ess', '@serverless']); From d748bc38e110eba3aa8850b9cdf92bbc5253f786 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 21 Nov 2023 16:05:42 +0000 Subject: [PATCH 11/28] skip flaky suite (#171168) --- .../cypress/e2e/artifacts/artifacts_mocked_data.cy.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifacts_mocked_data.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifacts_mocked_data.cy.ts index e7f32820c00d7d..531b118f3fcd3b 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifacts_mocked_data.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifacts_mocked_data.cy.ts @@ -31,7 +31,8 @@ const loginWithoutAccess = (url: string) => { loadPage(url); }; -describe('Artifacts pages', { tags: ['@ess', '@serverless'] }, () => { +// FLAKY: https://github.com/elastic/kibana/issues/171168 +describe.skip('Artifacts pages', { tags: ['@ess', '@serverless'] }, () => { let endpointData: ReturnTypeFromChainable | undefined; before(() => { From c4171beda054e235379c127b18b7c8b607b26453 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 21 Nov 2023 16:07:14 +0000 Subject: [PATCH 12/28] skip flaky suite (#171649) --- .../apps/endpoint/endpoint_permissions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_permissions.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_permissions.ts index 061e8936bf241d..dcea958cde8924 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_permissions.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_permissions.ts @@ -17,7 +17,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const testSubjects = getService('testSubjects'); const endpointTestResources = getService('endpointTestResources'); - describe('Endpoint permissions:', function () { + // FLAKY: https://github.com/elastic/kibana/issues/171649 + describe.skip('Endpoint permissions:', function () { targetTags(this, ['@ess']); let indexedData: IndexedHostsAndAlertsResponse; From 7e6bf40e4949bb795d5a7739f51b4ba5b8db4fe6 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 21 Nov 2023 16:08:03 +0000 Subject: [PATCH 13/28] skip flaky suite (#171650) --- .../apps/endpoint/endpoint_permissions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_permissions.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_permissions.ts index dcea958cde8924..3c31e714ae10bc 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_permissions.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_permissions.ts @@ -18,6 +18,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const endpointTestResources = getService('endpointTestResources'); // FLAKY: https://github.com/elastic/kibana/issues/171649 + // FLAKY: https://github.com/elastic/kibana/issues/171650 describe.skip('Endpoint permissions:', function () { targetTags(this, ['@ess']); From 38f8765e71ae5c11c8b0a3009179686d95650ae4 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 21 Nov 2023 16:10:44 +0000 Subject: [PATCH 14/28] skip flaky suite (#171641) --- .../cypress/e2e/response_actions/reponse_actions_history.cy.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/reponse_actions_history.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/reponse_actions_history.cy.ts index 4efd03c01d05bc..0af8ee43f29883 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/reponse_actions_history.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/response_actions/reponse_actions_history.cy.ts @@ -10,7 +10,8 @@ import { indexEndpointHosts } from '../../tasks/index_endpoint_hosts'; import { login } from '../../tasks/login'; import { loadPage } from '../../tasks/common'; -describe('Response actions history page', { tags: ['@ess', '@serverless'] }, () => { +// FLAKY: https://github.com/elastic/kibana/issues/171641 +describe.skip('Response actions history page', { tags: ['@ess', '@serverless'] }, () => { let endpointData: ReturnTypeFromChainable; // let actionData: ReturnTypeFromChainable; From e9dba60ef99563ecdae2509ce12339b02a237e0f Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 21 Nov 2023 16:11:58 +0000 Subject: [PATCH 15/28] skip flaky suite (#170985) --- .../e2e/serverless/roles/essentials_with_endpoint.roles.cy.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/essentials_with_endpoint.roles.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/essentials_with_endpoint.roles.cy.ts index 4e0ae2080a97b7..03ff413405b53f 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/essentials_with_endpoint.roles.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/essentials_with_endpoint.roles.cy.ts @@ -23,7 +23,8 @@ import { ensurePolicyDetailsPageAuthzAccess, } from '../../../screens'; -describe( +// FLAKY: https://github.com/elastic/kibana/issues/170985 +describe.skip( 'Roles for Security Essential PLI with Endpoint Essentials addon', { tags: ['@serverless'], From 7992ca1673fb146153bcf2c8b940a81eba55f8a1 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 21 Nov 2023 16:17:22 +0000 Subject: [PATCH 16/28] skip flaky suite (#170052) --- .../e2e/serverless/roles/complete_with_endpoint_roles.cy.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/complete_with_endpoint_roles.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/complete_with_endpoint_roles.cy.ts index a197574035b79f..722b31aca0391d 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/complete_with_endpoint_roles.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/complete_with_endpoint_roles.cy.ts @@ -30,7 +30,8 @@ import { openConsoleHelpPanel, } from '../../../screens'; -describe( +// FLAKY: https://github.com/elastic/kibana/issues/170052 +describe.skip( 'User Roles for Security Complete PLI with Endpoint Complete addon', { tags: ['@serverless'], From 532799fd639eec98ed937cb91f3bf543b39a825f Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 21 Nov 2023 16:26:39 +0000 Subject: [PATCH 17/28] skip flaky suite (#171667) --- .../apis/endpoint_response_actions/execute.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_response_actions/execute.ts b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_response_actions/execute.ts index f904539dae231a..68b81d5c0c1cf8 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_response_actions/execute.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_response_actions/execute.ts @@ -16,7 +16,8 @@ export default function ({ getService }: FtrProviderContext) { const supertestWithoutAuth = getService('supertestWithoutAuth'); const endpointTestResources = getService('endpointTestResources'); - describe('Endpoint `execute` response action', function () { + // FLAKY: https://github.com/elastic/kibana/issues/171667 + describe.skip('Endpoint `execute` response action', function () { targetTags(this, ['@ess', '@serverless']); let indexedData: IndexedHostsAndAlertsResponse; From d40855765e1d7f0048f67865f799ce73c2021874 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 21 Nov 2023 16:27:46 +0000 Subject: [PATCH 18/28] skip flaky suite (#171666) --- .../apis/endpoint_response_actions/execute.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_response_actions/execute.ts b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_response_actions/execute.ts index 68b81d5c0c1cf8..8d297f82befee5 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_response_actions/execute.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_response_actions/execute.ts @@ -17,6 +17,7 @@ export default function ({ getService }: FtrProviderContext) { const endpointTestResources = getService('endpointTestResources'); // FLAKY: https://github.com/elastic/kibana/issues/171667 + // FLAKY: https://github.com/elastic/kibana/issues/171666 describe.skip('Endpoint `execute` response action', function () { targetTags(this, ['@ess', '@serverless']); From a5d181089a17ae23ad638390525ae2c257bd3ac9 Mon Sep 17 00:00:00 2001 From: Konrad Szwarc Date: Tue, 21 Nov 2023 18:29:51 +0100 Subject: [PATCH 19/28] [EDR Workflows] Remove optional label from timeout input (#171632) https://github.com/elastic/kibana/issues/171617 ![Screenshot 2023-11-21 at 13 21 09](https://github.com/elastic/kibana/assets/29123534/404b948b-33bf-468b-8552-ec2062287c0c) --- .../osquery/public/form/timeout_field.tsx | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/x-pack/plugins/osquery/public/form/timeout_field.tsx b/x-pack/plugins/osquery/public/form/timeout_field.tsx index a9ad26d11e1736..5ef6627ba2c439 100644 --- a/x-pack/plugins/osquery/public/form/timeout_field.tsx +++ b/x-pack/plugins/osquery/public/form/timeout_field.tsx @@ -8,14 +8,7 @@ import React, { useCallback, useMemo } from 'react'; import deepEqual from 'fast-deep-equal'; import { useController } from 'react-hook-form'; import type { EuiFieldNumberProps } from '@elastic/eui'; -import { - EuiFieldNumber, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiIconTip, - EuiText, -} from '@elastic/eui'; +import { EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiIconTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -81,16 +74,6 @@ const TimeoutFieldComponent = ({ euiFieldProps }: TimeoutFieldProps) => { fullWidth error={error?.message} isInvalid={hasError} - labelAppend={ - - - - - - } > Date: Tue, 21 Nov 2023 11:00:32 -0700 Subject: [PATCH 20/28] [Dashboard Navigation] Simplify state management (#171581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/elastic/kibana/issues/167577 ## Summary Previously, the Link embeddable used the whole redux embeddable package - however, the overall state that needs to be managed for this panel is very simple, so this ended up being overkill. This PR fixes that by adding a `useLinksAttributes` hook to replace the redux package that subscribes to changes made to the attributes instead. I also made two smaller changes in this PR: 1. Called the "Organize imports" command from VSCode on all of the touched files - this explains all of the seemingly unrelated import changes. 2. I fixed the React warning that was being thrown due to calling `setIsSaving` after the component was unmounted. ### How to Test To test number 2 above, create a by-reference Links panel and refresh the dashboard. Then, 1. Make some sort of change to the Links panel, such as re-arranging the links 2. Save the changes - note that, without the mount check, the following React error will be thrown: ![image](https://github.com/elastic/kibana/assets/8698078/88573c7b-8469-490d-83dd-5e335573aa75) 3. Now, with the mount check, this no longer happens 🎉 ### Checklist - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../dashboard_link_component.tsx | 16 ++-- .../public/components/editor/links_editor.tsx | 38 +++++---- .../public/components/links_component.tsx | 33 ++++---- .../links/public/components/links_hooks.tsx | 38 +++++++++ .../public/embeddable/links_embeddable.tsx | 84 +++++-------------- .../embeddable/links_embeddable_factory.ts | 25 +++--- .../links/public/embeddable/links_reducers.ts | 27 ------ 7 files changed, 117 insertions(+), 144 deletions(-) create mode 100644 src/plugins/links/public/components/links_hooks.tsx delete mode 100644 src/plugins/links/public/embeddable/links_reducers.ts diff --git a/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx index 5ff2bacaf49fe1..4ad4608080b52c 100644 --- a/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx @@ -7,24 +7,24 @@ */ import classNames from 'classnames'; -import useAsync from 'react-use/lib/useAsync'; import React, { useEffect, useMemo, useState } from 'react'; +import useAsync from 'react-use/lib/useAsync'; import useObservable from 'react-use/lib/useObservable'; -import { - DashboardDrilldownOptions, - DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, -} from '@kbn/presentation-util-plugin/public'; import { EuiButtonEmpty, EuiListGroupItem } from '@elastic/eui'; -import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { DashboardLocatorParams, getDashboardLocatorParamsFromEmbeddable, } from '@kbn/dashboard-plugin/public'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; +import { + DashboardDrilldownOptions, + DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, +} from '@kbn/presentation-util-plugin/public'; -import { LINKS_VERTICAL_LAYOUT, LinksLayoutType, Link } from '../../../common/content_management'; +import { Link, LinksLayoutType, LINKS_VERTICAL_LAYOUT } from '../../../common/content_management'; +import { useLinks } from '../links_hooks'; import { DashboardLinkStrings } from './dashboard_link_strings'; -import { useLinks } from '../../embeddable/links_embeddable'; import { fetchDashboard } from './dashboard_link_tools'; export const DashboardLinkComponent = ({ diff --git a/src/plugins/links/public/components/editor/links_editor.tsx b/src/plugins/links/public/components/editor/links_editor.tsx index 0fb22efaf85078..12e48e5aa463fb 100644 --- a/src/plugins/links/public/components/editor/links_editor.tsx +++ b/src/plugins/links/public/components/editor/links_editor.tsx @@ -7,41 +7,42 @@ */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import useMountedState from 'react-use/lib/useMountedState'; import { - EuiForm, EuiBadge, - EuiTitle, EuiButton, - EuiSwitch, - EuiFormRow, - EuiToolTip, - EuiFlexItem, - EuiFlexGroup, - EuiDroppable, - EuiDraggable, - EuiFlyoutBody, EuiButtonEmpty, EuiButtonGroup, - EuiFlyoutFooter, - EuiFlyoutHeader, + EuiButtonGroupOptionProps, EuiDragDropContext, euiDragDropReorder, - EuiButtonGroupOptionProps, + EuiDraggable, + EuiDroppable, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiForm, + EuiFormRow, + EuiSwitch, + EuiTitle, + EuiToolTip, } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { LinksLayoutInfo } from '../../embeddable/types'; import { Link, LinksLayoutType, LINKS_HORIZONTAL_LAYOUT, LINKS_VERTICAL_LAYOUT, } from '../../../common/content_management'; +import { memoizedGetOrderedLinkList } from '../../editor/links_editor_tools'; +import { openLinkEditorFlyout } from '../../editor/open_link_editor_flyout'; +import { LinksLayoutInfo } from '../../embeddable/types'; import { coreServices } from '../../services/kibana_services'; import { LinksStrings } from '../links_strings'; -import { openLinkEditorFlyout } from '../../editor/open_link_editor_flyout'; -import { memoizedGetOrderedLinkList } from '../../editor/links_editor_tools'; import { LinksEditorEmptyPrompt } from './links_editor_empty_prompt'; import { LinksEditorSingleLink } from './links_editor_single_link'; @@ -80,6 +81,7 @@ const LinksEditor = ({ isByReference: boolean; }) => { const toasts = coreServices.notifications.toasts; + const isMounted = useMountedState(); const editLinkFlyoutRef = useRef(null); const [currentLayout, setCurrentLayout] = useState( @@ -294,7 +296,9 @@ const LinksEditor = ({ }); }) .finally(() => { - setIsSaving(false); + if (isMounted()) { + setIsSaving(false); + } }); } else { onAddToDashboard(orderedLinks, currentLayout); diff --git a/src/plugins/links/public/components/links_component.tsx b/src/plugins/links/public/components/links_component.tsx index c72c0db04fd570..0da40365abad0c 100644 --- a/src/plugins/links/public/components/links_component.tsx +++ b/src/plugins/links/public/components/links_component.tsx @@ -6,29 +6,28 @@ * Side Public License, v 1. */ +import { EuiListGroup, EuiPanel } from '@elastic/eui'; import React, { useEffect, useMemo } from 'react'; import useMap from 'react-use/lib/useMap'; -import { EuiListGroup, EuiPanel } from '@elastic/eui'; -import { useLinks } from '../embeddable/links_embeddable'; -import { ExternalLinkComponent } from './external_link/external_link_component'; -import { DashboardLinkComponent } from './dashboard_link/dashboard_link_component'; -import { memoizedGetOrderedLinkList } from '../editor/links_editor_tools'; import { DASHBOARD_LINK_TYPE, LINKS_HORIZONTAL_LAYOUT, LINKS_VERTICAL_LAYOUT, } from '../../common/content_management'; +import { memoizedGetOrderedLinkList } from '../editor/links_editor_tools'; +import { DashboardLinkComponent } from './dashboard_link/dashboard_link_component'; +import { ExternalLinkComponent } from './external_link/external_link_component'; import './links_component.scss'; +import { useLinks, useLinksAttributes } from './links_hooks'; export const LinksComponent = () => { const linksEmbeddable = useLinks(); - const links = linksEmbeddable.select((state) => state.componentState.links); - const layout = linksEmbeddable.select((state) => state.componentState.layout); + const linksAttributes = useLinksAttributes(); const [linksLoading, { set: setLinkIsLoading }] = useMap( Object.fromEntries( - (links ?? []).map((link) => { + (linksAttributes?.links ?? []).map((link) => { return [link.id, true]; }) ) @@ -43,12 +42,12 @@ export const LinksComponent = () => { }, [linksLoading, linksEmbeddable]); const orderedLinks = useMemo(() => { - if (!links) return []; - return memoizedGetOrderedLinkList(links); - }, [links]); + if (!linksAttributes?.links) return []; + return memoizedGetOrderedLinkList(linksAttributes?.links); + }, [linksAttributes]); const linkItems: { [id: string]: { id: string; content: JSX.Element } } = useMemo(() => { - return (links ?? []).reduce((prev, currentLink) => { + return (linksAttributes?.links ?? []).reduce((prev, currentLink) => { return { ...prev, [currentLink.id]: { @@ -58,7 +57,7 @@ export const LinksComponent = () => { setLinkIsLoading(currentLink.id, true)} onRender={() => setLinkIsLoading(currentLink.id, false)} /> @@ -66,26 +65,26 @@ export const LinksComponent = () => { setLinkIsLoading(currentLink.id, false)} /> ), }, }; }, {}); - }, [links, layout, setLinkIsLoading]); + }, [linksAttributes?.links, linksAttributes?.layout, setLinkIsLoading]); return ( {orderedLinks.map((link) => linkItems[link.id].content)} diff --git a/src/plugins/links/public/components/links_hooks.tsx b/src/plugins/links/public/components/links_hooks.tsx new file mode 100644 index 00000000000000..aa33c9d0f3ac10 --- /dev/null +++ b/src/plugins/links/public/components/links_hooks.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useContext, useEffect, useState } from 'react'; + +import { LinksAttributes } from '../../common/content_management'; +import { LinksContext, LinksEmbeddable } from '../embeddable/links_embeddable'; + +export const useLinks = (): LinksEmbeddable => { + const linksEmbeddable = useContext(LinksContext); + if (linksEmbeddable == null) { + throw new Error('useLinks must be used inside LinksContext.'); + } + return linksEmbeddable!; +}; + +export const useLinksAttributes = (): LinksAttributes | undefined => { + const linksEmbeddable = useLinks(); + const [attributes, setAttributes] = useState( + linksEmbeddable.attributes + ); + + useEffect(() => { + const attributesSubscription = linksEmbeddable.attributes$.subscribe((newAttributes) => { + setAttributes(newAttributes); + }); + return () => { + attributesSubscription.unsubscribe(); + }; + }, [linksEmbeddable.attributes$]); + + return attributes; +}; diff --git a/src/plugins/links/public/embeddable/links_embeddable.tsx b/src/plugins/links/public/embeddable/links_embeddable.tsx index 032b0099ed4518..d803b9df9e8c5d 100644 --- a/src/plugins/links/public/embeddable/links_embeddable.tsx +++ b/src/plugins/links/public/embeddable/links_embeddable.tsx @@ -6,37 +6,25 @@ * Side Public License, v 1. */ -import React, { createContext, useContext } from 'react'; -import { unmountComponentAtNode } from 'react-dom'; -import { Subscription, distinctUntilChanged, skip, switchMap } from 'rxjs'; import deepEqual from 'fast-deep-equal'; +import React, { createContext } from 'react'; +import { unmountComponentAtNode } from 'react-dom'; +import { distinctUntilChanged, skip, Subject, Subscription, switchMap } from 'rxjs'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { AttributeService, Embeddable, ReferenceOrValueEmbeddable, SavedObjectEmbeddableInput, } from '@kbn/embeddable-plugin/public'; -import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; -import { linksReducers } from './links_reducers'; -import { LinksByReferenceInput, LinksByValueInput, LinksReduxState } from './types'; -import { LinksComponent } from '../components/links_component'; -import { LinksInput, LinksOutput } from './types'; -import { LinksAttributes } from '../../common/content_management'; import { CONTENT_ID } from '../../common'; +import { LinksAttributes } from '../../common/content_management'; +import { LinksComponent } from '../components/links_component'; +import { LinksByReferenceInput, LinksByValueInput, LinksInput, LinksOutput } from './types'; export const LinksContext = createContext(null); -export const useLinks = (): LinksEmbeddable => { - const linksEmbeddable = useContext(LinksContext); - if (linksEmbeddable == null) { - throw new Error('useLinks must be used inside LinksContext.'); - } - return linksEmbeddable!; -}; - -type LinksReduxEmbeddableTools = ReduxEmbeddableTools; export interface LinksConfig { editable: boolean; @@ -53,20 +41,10 @@ export class LinksEmbeddable private isDestroyed?: boolean; private subscriptions: Subscription = new Subscription(); - // state management - /** - * TODO: Keep track of the necessary state without the redux embeddable tools; it's kind of overkill here. - * Related issue: https://github.com/elastic/kibana/issues/167577 - */ - public select: LinksReduxEmbeddableTools['select']; - public getState: LinksReduxEmbeddableTools['getState']; - public dispatch: LinksReduxEmbeddableTools['dispatch']; - public onStateChange: LinksReduxEmbeddableTools['onStateChange']; - - private cleanupStateTools: () => void; + public attributes?: LinksAttributes; + public attributes$ = new Subject(); constructor( - reduxToolsPackage: ReduxToolsPackage, config: LinksConfig, initialInput: LinksInput, private attributeService: AttributeService, @@ -81,29 +59,11 @@ export class LinksEmbeddable parent ); - /** Build redux embeddable tools */ - const reduxEmbeddableTools = reduxToolsPackage.createReduxEmbeddableTools< - LinksReduxState, - typeof linksReducers - >({ - embeddable: this, - reducers: linksReducers, - initialComponentState: { - title: '', - }, - }); - - this.select = reduxEmbeddableTools.select; - this.getState = reduxEmbeddableTools.getState; - this.dispatch = reduxEmbeddableTools.dispatch; - this.cleanupStateTools = reduxEmbeddableTools.cleanup; - this.onStateChange = reduxEmbeddableTools.onStateChange; - this.initializeSavedLinks() .then(() => this.setInitializationFinished()) .catch((e: Error) => this.onFatalError(e)); - // By-value panels should update the componentState when input changes + // By-value panels should update the links attributes when input changes this.subscriptions.add( this.getInput$() .pipe( @@ -113,25 +73,28 @@ export class LinksEmbeddable ) .subscribe() ); + + // Keep attributes in sync with subject value so it can be used in output + this.subscriptions.add( + this.attributes$.pipe(distinctUntilChanged(deepEqual)).subscribe((attributes) => { + this.attributes = attributes; + }) + ); } private async initializeSavedLinks() { const { attributes } = await this.attributeService.unwrapAttributes(this.getInput()); - if (this.isDestroyed) return; - - this.dispatch.setAttributes(attributes); - + this.attributes$.next(attributes); await this.initializeOutput(); } private async initializeOutput() { - const attributes = this.getState().componentState; const { title, description } = this.getInput(); this.updateOutput({ - defaultTitle: attributes.title, - defaultDescription: attributes.description, - title: title ?? attributes.title, - description: description ?? attributes.description, + defaultTitle: this.attributes?.title, + defaultDescription: this.attributes?.description, + title: title ?? this.attributes?.title, + description: description ?? this.attributes?.description, }); } @@ -162,7 +125,7 @@ export class LinksEmbeddable public async reload() { if (this.isDestroyed) return; - // By-reference embeddable panels are reloaded when changed, so update the componentState + // By-reference embeddable panels are reloaded when changed, so update the attributes this.initializeSavedLinks(); if (this.domNode) { this.render(this.domNode); @@ -173,7 +136,6 @@ export class LinksEmbeddable this.isDestroyed = true; super.destroy(); this.subscriptions.unsubscribe(); - this.cleanupStateTools(); if (this.domNode) { unmountComponentAtNode(this.domNode); } diff --git a/src/plugins/links/public/embeddable/links_embeddable_factory.ts b/src/plugins/links/public/embeddable/links_embeddable_factory.ts index e0502d34a742c3..55838c6d652296 100644 --- a/src/plugins/links/public/embeddable/links_embeddable_factory.ts +++ b/src/plugins/links/public/embeddable/links_embeddable_factory.ts @@ -6,34 +6,33 @@ * Side Public License, v 1. */ +import { DASHBOARD_GRID_COLUMN_COUNT } from '@kbn/dashboard-plugin/public'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; +import { IProvidesPanelPlacementSettings } from '@kbn/dashboard-plugin/public/dashboard_container/component/panel_placement/types'; +import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; import { EmbeddableFactory, EmbeddableFactoryDefinition, ErrorEmbeddable, } from '@kbn/embeddable-plugin/public'; import { - MigrateFunctionsObject, GetMigrationFunctionObjectFn, + MigrateFunctionsObject, } from '@kbn/kibana-utils-plugin/common'; -import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; -import { DASHBOARD_GRID_COLUMN_COUNT } from '@kbn/dashboard-plugin/public'; import { UiActionsPresentableGrouping } from '@kbn/ui-actions-plugin/public'; -import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; -import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { IProvidesPanelPlacementSettings } from '@kbn/dashboard-plugin/public/dashboard_container/component/panel_placement/types'; +import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common'; +import { LinksAttributes } from '../../common/content_management'; +import { extract, inject } from '../../common/embeddable'; +import { LinksStrings } from '../components/links_strings'; +import { getLinksAttributeService } from '../services/attribute_service'; import { coreServices, presentationUtil, untilPluginStartServicesReady, } from '../services/kibana_services'; -import { extract, inject } from '../../common/embeddable'; import type { LinksEmbeddable } from './links_embeddable'; -import { LinksStrings } from '../components/links_strings'; -import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common'; -import { LinksAttributes } from '../../common/content_management'; -import { getLinksAttributeService } from '../services/attribute_service'; -import { LinksInput, LinksByReferenceInput, LinksEditorFlyoutReturn } from './types'; +import { LinksByReferenceInput, LinksEditorFlyoutReturn, LinksInput } from './types'; export type LinksFactory = EmbeddableFactory; @@ -111,12 +110,10 @@ export class LinksFactoryDefinition public async create(initialInput: LinksInput, parent: DashboardContainer) { await untilPluginStartServicesReady(); - const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage(); const { LinksEmbeddable } = await import('./links_embeddable'); const editable = await this.isEditable(); return new LinksEmbeddable( - reduxEmbeddablePackage, { editable }, { ...getDefaultLinksInput(), ...initialInput }, getLinksAttributeService(), diff --git a/src/plugins/links/public/embeddable/links_reducers.ts b/src/plugins/links/public/embeddable/links_reducers.ts deleted file mode 100644 index 659b19058adbb8..00000000000000 --- a/src/plugins/links/public/embeddable/links_reducers.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { WritableDraft } from 'immer/dist/types/types-external'; - -import { PayloadAction } from '@reduxjs/toolkit'; - -import { LinksReduxState } from './types'; -import { LinksAttributes } from '../../common/content_management'; - -export const linksReducers = { - setLoading: (state: WritableDraft, action: PayloadAction) => { - state.output.loading = action.payload; - }, - - setAttributes: ( - state: WritableDraft, - action: PayloadAction - ) => { - state.componentState = { ...action.payload }; - }, -}; From e79ca5e9d6ab179819634cf654fc2c4b0ed0413c Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Tue, 21 Nov 2023 13:13:55 -0600 Subject: [PATCH 21/28] Revert "re-enable kme pipeline for testing (#171451)" This reverts commit c0978dbe1be0618577526e051826be5eb6476645. --- .buildkite/pull_requests.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.buildkite/pull_requests.json b/.buildkite/pull_requests.json index 65a3ff6ba2004a..41de2dc843d4da 100644 --- a/.buildkite/pull_requests.json +++ b/.buildkite/pull_requests.json @@ -55,14 +55,14 @@ "repoName": "kibana", "pipelineSlug": "kibana-kme-test", - "enabled": true, + "enabled": false, "allow_org_users": true, "allowed_repo_permissions": ["admin", "write"], "allowed_list": ["barlowm", "renovate[bot]"], "set_commit_status": true, - "commit_status_context": "kibana-ci-test", + "commit_status_context": "kibana-ci", "build_on_commit": true, - "build_on_comment": false, + "build_on_comment": true, "trigger_comment_regex": "^(?:(?:buildkite\\W+)?(?:build|test)\\W+(?:this|it))", "always_trigger_comment_regex": "^(?:(?:buildkite\\W+)?(?:build|test)\\W+(?:this|it))", "skip_ci_labels": [], From 10f422836be9690201cdea2fbccfc94adb4cd6a4 Mon Sep 17 00:00:00 2001 From: Jan Monschke Date: Tue, 21 Nov 2023 22:42:00 +0100 Subject: [PATCH 22/28] [SecuritySolution] Fix timeline saving / prevent epic from crashing (#171674) ## Summary Fixes https://github.com/elastic/kibana/issues/168194 Under some circumstance, when navigating to the timelines page, we would get a runtime exception for `state.tableById[action.id]` not being defined. When that happened, the redux store would be in a broken state. This PR makes the responsible destructuring assignment more save. --- .../security-solution/data_table/store/data_table/reducer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/packages/security-solution/data_table/store/data_table/reducer.ts b/x-pack/packages/security-solution/data_table/store/data_table/reducer.ts index f65292a2919e40..1ad08a9332503a 100644 --- a/x-pack/packages/security-solution/data_table/store/data_table/reducer.ts +++ b/x-pack/packages/security-solution/data_table/store/data_table/reducer.ts @@ -95,7 +95,7 @@ export const dataTableReducer = reducerWithInitialState(initialDataTableState) [action.id]: { ...state.tableById[action.id], expandedDetail: { - ...state.tableById[action.id].expandedDetail, + ...state.tableById[action.id]?.expandedDetail, ...updateTableDetailsPanel(action), }, }, From d392473d9020f027a55fc98326b1e8ea1e0374be Mon Sep 17 00:00:00 2001 From: Brad White Date: Tue, 21 Nov 2023 14:43:15 -0700 Subject: [PATCH 23/28] [chore] Restrict Storybook version for Renovate (#171453) Renovate bot keeps updating Storybook in #169655 to 7.x which has [significant breaking changes](https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#from-version-65x-to-700), CI failures, and requires Webpack 5. This upgrade will require a human due to how our SB is setup. [Renovate Docs](https://docs.renovatebot.com/configuration-options/#allowedversions) --- renovate.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/renovate.json b/renovate.json index e4c1e0672c7829..63ca5d0ba8a972 100644 --- a/renovate.json +++ b/renovate.json @@ -462,7 +462,8 @@ "Team:Operations", "release_note:skip" ], - "enabled": true + "enabled": true, + "allowedVersions": "<7.0" }, { "groupName": "react-query", @@ -647,4 +648,4 @@ "enabled": true } ] -} +} \ No newline at end of file From 043f0501874108eeb133d59ecded2d1e7a871bd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= Date: Tue, 21 Nov 2023 21:50:37 +0000 Subject: [PATCH 24/28] [security_solution] Fix junit_transformer (#171669) ## Summary Currently, Cypress is writing junit XML files that we are trying to map to the expected CI format, but if the job fails the broken files are still being uploaded and passed to the Flaky Test Reporter which causes it to fail. So the solution is to just delete the broken files before they are sent to the Flaky Tests Reporter Co-authored-by: Tiago Costa --- .../scripts/junit_transformer/lib.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/scripts/junit_transformer/lib.ts b/x-pack/plugins/security_solution/scripts/junit_transformer/lib.ts index d9033c75d485b2..725baa689cd207 100644 --- a/x-pack/plugins/security_solution/scripts/junit_transformer/lib.ts +++ b/x-pack/plugins/security_solution/scripts/junit_transformer/lib.ts @@ -16,6 +16,7 @@ import * as t from 'io-ts'; import { isLeft } from 'fp-ts/lib/Either'; import { PathReporter } from 'io-ts/lib/PathReporter'; import globby from 'globby'; +import del from 'del'; /** * Updates the `name` and `classname` attributes of each testcase. @@ -195,6 +196,9 @@ ${boilerplate} if ('error' in maybeValidationResult) { logError(maybeValidationResult.error); + // Sending broken XML to Failed Test Reporter will cause a job to fail + await del(path); + log.warning(`${path} file was deleted.`); // If there is an error, continue trying to process other files. continue; } @@ -203,9 +207,11 @@ ${boilerplate} const { processed, hadTestCases } = isReportAlreadyProcessed(reportJson); if (hadTestCases === false) { - log.warning(`${path} had no test cases. Skipping it. + log.warning(`${path} had no test cases. ${boilerplate} `); + await del(path); + log.warning(`${path} file was deleted.`); // If there is an error, continue trying to process other files. continue; } @@ -222,6 +228,9 @@ ${boilerplate} if ('error' in maybeSpecFilePath) { logError(maybeSpecFilePath.error); + // Sending broken XML to Failed Test Reporter will cause a job to fail + await del(path); + log.warning(`${path} file was deleted.`); // If there is an error, continue trying to process other files. continue; } From 1919c87b90c4f489c2027d71293631d89034f40f Mon Sep 17 00:00:00 2001 From: Brad White Date: Tue, 21 Nov 2023 14:59:39 -0700 Subject: [PATCH 25/28] Remove CI Composite Storybook (#171258) ## Summary Closes #160803 This PR removes the `CI Composite` story because it has been broken since at least ac23dce29f07f0539eb8d3f0968ac36441729207 (and possibly since b862a6c1810f5f4c14f8f657411f781b152dec0d). The functionality is covered by the generated `index.html` in https://github.com/elastic/kibana/blob/dda4498fee84708143ce4671af880db785f9e652/.buildkite/scripts/steps/storybooks/build_and_upload.ts#L105-L120 To fix the composite story requires generating `stories.json` for every storybook, which requires migrating the repo off the deprecated `storiesOf` API. That task is quite extensive and would be better handled alongside an upgrade to Storybook 7.x --- .../steps/storybooks/build_and_upload.ts | 7 +--- .ci/.storybook/main.js | 34 ------------------- src/dev/storybook/aliases.ts | 1 - 3 files changed, 1 insertion(+), 41 deletions(-) delete mode 100644 .ci/.storybook/main.js diff --git a/.buildkite/scripts/steps/storybooks/build_and_upload.ts b/.buildkite/scripts/steps/storybooks/build_and_upload.ts index 83f1ecb2a759dd..19fed0e78885fc 100644 --- a/.buildkite/scripts/steps/storybooks/build_and_upload.ts +++ b/.buildkite/scripts/steps/storybooks/build_and_upload.ts @@ -16,7 +16,6 @@ const STORYBOOKS = [ 'canvas', 'cases', 'cell_actions', - 'ci_composite', 'cloud_chat', 'coloring', 'chart_icons', @@ -93,14 +92,12 @@ const upload = () => { console.log('--- Generating Storybooks HTML'); process.chdir(path.join('.', 'built_assets', 'storybook')); - fs.renameSync('ci_composite', 'composite'); const storybooks = execSync(`ls -1d */`) .toString() .trim() .split('\n') - .map((filePath) => filePath.replace('/', '')) - .filter((filePath) => filePath !== 'composite'); + .map((filePath) => filePath.replace('/', '')); const listHtml = storybooks .map((storybook) => `
  • ${storybook}
  • `) @@ -110,8 +107,6 @@ const upload = () => {

    Storybooks

    -

    Composite Storybook

    -

    All

      ${listHtml}
    diff --git a/.ci/.storybook/main.js b/.ci/.storybook/main.js deleted file mode 100644 index c4e017179021a0..00000000000000 --- a/.ci/.storybook/main.js +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -const config = require('@kbn/storybook').defaultConfig; -const aliases = require('../../src/dev/storybook/aliases').storybookAliases; - -config.refs = {}; - -// Required due to https://github.com/storybookjs/storybook/issues/13834 -config.babel = async (options) => ({ - ...options, - plugins: ['@babel/plugin-transform-typescript', ...options.plugins], -}); - -for (const alias of Object.keys(aliases).filter((a) => a !== 'ci_composite')) { - // snake_case -> Title Case - const title = alias - .replace(/_/g, ' ') - .split(' ') - .map((n) => n[0].toUpperCase() + n.slice(1)) - .join(' '); - - config.refs[alias] = { - title: title, - url: `${process.env.STORYBOOK_BASE_URL}/${alias}`, - }; -} - -module.exports = config; diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 98470ccf136580..88d8c04b42337b 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -15,7 +15,6 @@ export const storybookAliases = { canvas: 'x-pack/plugins/canvas/storybook', cases: 'packages/kbn-cases-components/.storybook', cell_actions: 'packages/kbn-cell-actions/.storybook', - ci_composite: '.ci/.storybook', cloud_chat: 'x-pack/plugins/cloud_integrations/cloud_chat/.storybook', coloring: 'packages/kbn-coloring/.storybook', language_documentation_popover: 'packages/kbn-language-documentation-popover/.storybook', From 07df5966b297a64dfcbc8ce1f3fb84b7e7c6fbaf Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 21 Nov 2023 16:13:27 -0600 Subject: [PATCH 26/28] Revert "Revert "re-enable kme pipeline for testing (#171451)"" (#171694) I pushed this revert up initially via e79ca5e9d6ab179819634cf654fc2c4b0ed0413c while debugging an issue with CI waiting for agents. This was not the root cause and can be unreverted. --- .buildkite/pull_requests.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.buildkite/pull_requests.json b/.buildkite/pull_requests.json index 41de2dc843d4da..65a3ff6ba2004a 100644 --- a/.buildkite/pull_requests.json +++ b/.buildkite/pull_requests.json @@ -55,14 +55,14 @@ "repoName": "kibana", "pipelineSlug": "kibana-kme-test", - "enabled": false, + "enabled": true, "allow_org_users": true, "allowed_repo_permissions": ["admin", "write"], "allowed_list": ["barlowm", "renovate[bot]"], "set_commit_status": true, - "commit_status_context": "kibana-ci", + "commit_status_context": "kibana-ci-test", "build_on_commit": true, - "build_on_comment": true, + "build_on_comment": false, "trigger_comment_regex": "^(?:(?:buildkite\\W+)?(?:build|test)\\W+(?:this|it))", "always_trigger_comment_regex": "^(?:(?:buildkite\\W+)?(?:build|test)\\W+(?:this|it))", "skip_ci_labels": [], From 5cbc93c53392e61d49aea20277b5f7c4dddd11cf Mon Sep 17 00:00:00 2001 From: Florian Bernd Date: Tue, 21 Nov 2023 23:17:08 +0100 Subject: [PATCH 27/28] [Serverless Search] Getting Started - Fix .NET code snippet (#171388) Fixes the "Getting Started" code snippet for the .NET serverless client. --- .../public/application/components/languages/dotnet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/serverless_search/public/application/components/languages/dotnet.ts b/x-pack/plugins/serverless_search/public/application/components/languages/dotnet.ts index 59b7e9e5721c72..4f388d2227203d 100644 --- a/x-pack/plugins/serverless_search/public/application/components/languages/dotnet.ts +++ b/x-pack/plugins/serverless_search/public/application/components/languages/dotnet.ts @@ -23,7 +23,7 @@ export const dotnetDefinition: LanguageDefinition = { installClient: 'dotnet add package Elastic.Clients.Elasticsearch.Serverless', configureClient: ({ apiKey, cloudId }) => `using System; using Elastic.Clients.Elasticsearch.Serverless; -using Elastic.Clients.Elasticsearch.QueryDsl; +using Elastic.Clients.Elasticsearch.Serverless.QueryDsl; var client = new ElasticsearchClient("${cloudId}", new ApiKey("${apiKey}"));`, testConnection: `var info = await client.InfoAsync();`, From 641983ff8df42ffdabdd665a1a183e4dfefe7ede Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Tue, 21 Nov 2023 18:38:05 -0700 Subject: [PATCH 28/28] Append serverless project ID to Support URL (#171448) This PR updates the URL to the Elastic Support Portal registered by the Cloud plugin, to include the configured deployment ID as a querystring parameter. 1. On serverless deployments, we set the projects unique identifier with `?serverless_project_id=123ABC` 2. On stateful cloud deployments, we set the deployment's unique identifier with `?cloud_deployment_id=123ABC` 3. On on-prem deployments functionality shall remain unchanged. Where this link can be found in the UI: ![image](https://github.com/elastic/kibana/assets/908371/a00f0dad-5aa2-40ab-9667-746ebe774762) --- x-pack/plugins/cloud/public/plugin.test.ts | 41 ++++++++++++++++++++-- x-pack/plugins/cloud/public/plugin.tsx | 5 +-- x-pack/plugins/cloud/public/utils.ts | 21 +++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 x-pack/plugins/cloud/public/utils.ts diff --git a/x-pack/plugins/cloud/public/plugin.test.ts b/x-pack/plugins/cloud/public/plugin.test.ts index 3709dd7cfcb959..99e6f97946cca5 100644 --- a/x-pack/plugins/cloud/public/plugin.test.ts +++ b/x-pack/plugins/cloud/public/plugin.test.ts @@ -197,8 +197,10 @@ describe('Cloud Plugin', () => { return { coreSetup, plugin }; }; - it('registers help support URL', async () => { - const { plugin } = startPlugin(); + it('registers help support URL: default', async () => { + const { plugin } = startPlugin({ + id: undefined, + }); const coreStart = coreMock.createStart(); plugin.start(coreStart); @@ -211,6 +213,41 @@ describe('Cloud Plugin', () => { `); }); + it('registers help support URL: serverless projects', async () => { + const { plugin } = startPlugin({ + id: 'my-awesome-project-id', + serverless: { + project_id: 'my-awesome-serverless-project-id', + }, + }); + + const coreStart = coreMock.createStart(); + plugin.start(coreStart); + + expect(coreStart.chrome.setHelpSupportUrl).toHaveBeenCalledTimes(1); + expect(coreStart.chrome.setHelpSupportUrl.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "https://support.elastic.co/?serverless_project_id=my-awesome-serverless-project-id", + ] + `); + }); + + it('registers help support URL: non-serverless projects', async () => { + const { plugin } = startPlugin({ + id: 'my-awesome-project-id', + }); + + const coreStart = coreMock.createStart(); + plugin.start(coreStart); + + expect(coreStart.chrome.setHelpSupportUrl).toHaveBeenCalledTimes(1); + expect(coreStart.chrome.setHelpSupportUrl.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "https://support.elastic.co/?cloud_deployment_id=my-awesome-project-id", + ] + `); + }); + describe('isServerlessEnabled', () => { it('is `true` when `serverless.projectId` is set', () => { const { plugin } = startPlugin({ diff --git a/x-pack/plugins/cloud/public/plugin.tsx b/x-pack/plugins/cloud/public/plugin.tsx index 3aaaf8f14fe271..f0e7b8f713e807 100644 --- a/x-pack/plugins/cloud/public/plugin.tsx +++ b/x-pack/plugins/cloud/public/plugin.tsx @@ -11,10 +11,11 @@ import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kb import { registerCloudDeploymentMetadataAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context'; import { getIsCloudEnabled } from '../common/is_cloud_enabled'; import { parseDeploymentIdFromDeploymentUrl } from '../common/parse_deployment_id_from_deployment_url'; -import { ELASTIC_SUPPORT_LINK, CLOUD_SNAPSHOTS_PATH } from '../common/constants'; +import { CLOUD_SNAPSHOTS_PATH } from '../common/constants'; import { decodeCloudId, type DecodedCloudId } from '../common/decode_cloud_id'; import type { CloudSetup, CloudStart } from './types'; import { getFullCloudUrl } from '../common/utils'; +import { getSupportUrl } from './utils'; export interface CloudConfigType { id?: string; @@ -103,7 +104,7 @@ export class CloudPlugin implements Plugin { } public start(coreStart: CoreStart): CloudStart { - coreStart.chrome.setHelpSupportUrl(ELASTIC_SUPPORT_LINK); + coreStart.chrome.setHelpSupportUrl(getSupportUrl(this.config)); // Nest all the registered context providers under the Cloud Services Provider. // This way, plugins only need to require Cloud's context provider to have all the enriched Cloud services. diff --git a/x-pack/plugins/cloud/public/utils.ts b/x-pack/plugins/cloud/public/utils.ts new file mode 100644 index 00000000000000..d2381c428fe0a9 --- /dev/null +++ b/x-pack/plugins/cloud/public/utils.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ELASTIC_SUPPORT_LINK } from '../common/constants'; +import { CloudConfigType } from './plugin'; + +export function getSupportUrl(config: CloudConfigType): string { + let supportUrl = ELASTIC_SUPPORT_LINK; + if (config.serverless?.project_id) { + // serverless projects use config.id and config.serverless.project_id + supportUrl += '?serverless_project_id=' + config.serverless.project_id; + } else if (config.id) { + // non-serverless Cloud projects only use config.id + supportUrl += '?cloud_deployment_id=' + config.id; + } + return supportUrl; +}